Skip to main content

cranpose_ui/
render_state.rs

1#[cfg(test)]
2use std::sync::{Mutex, OnceLock};
3use std::{
4    cell::{Cell, RefCell},
5    collections::HashMap,
6    rc::{Rc, Weak},
7    sync::Arc,
8};
9
10use cranpose_core::{
11    NodeId, SnapshotStateObserver, collections::map::HashSet, current_runtime_handle,
12};
13
14pub(crate) type ModifierChainTraceCallback =
15    dyn Fn(&[crate::modifier::ModifierChainInspectorNode]) + Send + Sync + 'static;
16
17struct RenderState {
18    layout_repasses: RefCell<DirtyNodeSet>,
19    measure_repasses: RefCell<DirtyNodeSet>,
20    draw_repasses: RefCell<DirtyNodeSet>,
21    layer_property_repasses: RefCell<DirtyNodeSet>,
22    modifier_slice_repasses: RefCell<DirtyNodeSet>,
23    geometry_scene_nodes: RefCell<DirtyNodeSet>,
24    moved_scene_nodes: RefCell<DirtyNodeSet>,
25    render_invalidated: Cell<bool>,
26    pointer_invalidated: Cell<bool>,
27    focus_invalidated: Cell<bool>,
28    layout_invalidated: Cell<bool>,
29    density_bits: Cell<u32>,
30    font_scale: Cell<crate::font_scale::FontScaleCurve>,
31    /// Whether `font_scale` leaves every size as it is, read by every text
32    /// measured instead of the curve.
33    font_scale_identity: Cell<bool>,
34    /// `font_scale`'s fingerprint, computed when the curve is set.
35    font_scale_fingerprint: Cell<u32>,
36}
37
38#[doc(hidden)]
39pub struct AppContext {
40    id: AppContextId,
41    self_weak: RefCell<Weak<AppContext>>,
42    state: RenderState,
43    draw_observer: SnapshotStateObserver,
44    text: crate::text::measure::TextService,
45    layout_frame_arena: RefCell<crate::layout::FrameLayoutArena>,
46    layout_cache_epoch: Cell<u64>,
47    layout_cache_floor: Cell<u64>,
48    last_fling_velocity_bits: Cell<u32>,
49    scroll_motion_contexts: crate::scroll::ScrollMotionContextStore,
50    lazy_prefetch: crate::lazy_prefetch::LazyPrefetchState,
51    layout_node_registry: crate::widgets::nodes::layout_node::LayoutNodeRegistryState,
52    pointer_dispatch: crate::pointer_dispatch::PointerDispatchState,
53    mouse_input: crate::mouse_input::MouseInputQueue,
54    focus_dispatch: crate::focus_dispatch::FocusInvalidationState,
55    modal: crate::modal::ModalState,
56    modal_nodes: crate::modal_nodes::ModalNodes,
57    semantics_layout: crate::semantics_layout_log::SemanticsLayoutLog,
58    hosted_popups: crate::widgets::popup::HostedPopupRegistries,
59    semantics_dispatch: crate::semantics_dispatch::SemanticsInvalidationState,
60    cursor_animation: crate::cursor_animation::CursorAnimationState,
61    text_field_focus: crate::text_field_focus::TextFieldFocusState,
62    text_input_session: crate::text_input_session::PlatformTextInputState,
63    clipboard_session: crate::clipboard_session::ClipboardSessionState,
64    pointer_icon: crate::pointer_icon_session::PointerIconState,
65    pointer_input_tasks: crate::modifier::pointer_input::PointerInputTaskRegistry,
66    modifier_chain_trace: RefCell<Option<Arc<ModifierChainTraceCallback>>>,
67    window_roots: crate::modifier::WindowRootRegistry,
68    drag_and_drop: crate::modifier::DragAndDropState,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
72pub(crate) struct AppContextId(u64);
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
75pub(crate) struct DrawObservationScope {
76    node_id: NodeId,
77    modifier_index: usize,
78    command_index: usize,
79    kind: DrawObservationKind,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
83enum DrawObservationKind {
84    Content,
85    LayerProperties,
86}
87
88impl DrawObservationScope {
89    pub(crate) fn new(node_id: NodeId, modifier_index: usize, command_index: usize) -> Self {
90        Self {
91            node_id,
92            modifier_index,
93            command_index,
94            kind: DrawObservationKind::Content,
95        }
96    }
97
98    pub(crate) fn layer_properties(node_id: NodeId, modifier_index: usize) -> Self {
99        Self {
100            node_id,
101            modifier_index,
102            command_index: 0,
103            kind: DrawObservationKind::LayerProperties,
104        }
105    }
106}
107
108fn new_draw_observer() -> SnapshotStateObserver {
109    let observer = SnapshotStateObserver::new(|callback| {
110        if let Some(runtime) = current_runtime_handle() {
111            runtime.enqueue_ui_task(callback);
112        } else {
113            callback();
114        }
115    });
116    observer.start();
117    observer
118}
119
120thread_local! {
121    static CURRENT_DRAW_NODE: std::cell::Cell<Option<NodeId>> = const { std::cell::Cell::new(None) };
122}
123
124struct CurrentDrawNodeGuard {
125    previous: Option<NodeId>,
126}
127
128impl Drop for CurrentDrawNodeGuard {
129    fn drop(&mut self) {
130        CURRENT_DRAW_NODE.with(|current| current.set(self.previous));
131    }
132}
133
134pub(crate) fn observe_draw_reads<R>(scope: DrawObservationScope, block: impl FnOnce() -> R) -> R {
135    let context = require_current_app_context("draw observer access");
136    let context_id = context.id;
137    let _guard = CurrentDrawNodeGuard {
138        previous: CURRENT_DRAW_NODE.with(|current| current.replace(Some(scope.node_id))),
139    };
140    context.draw_observer.observe_reads(
141        scope,
142        move |scope| match scope.kind {
143            DrawObservationKind::Content => {
144                schedule_draw_repass_for_app_context(context_id, scope.node_id);
145            }
146            DrawObservationKind::LayerProperties => {
147                schedule_layer_property_repass_for_app_context(context_id, scope.node_id);
148            }
149        },
150        block,
151    )
152}
153
154/// The draw-phase animation contract: a draw closure that advances its own
155/// spring or clock (a Cell no observation can see) must schedule the next
156/// frame's re-record of ITS OWN node — a bare render invalidation only
157/// re-presents the retained scene, which would freeze the animation
158/// mid-flight. Callable only from inside a recording draw closure; anywhere
159/// else it degrades to a plain frame request.
160pub fn request_current_draw_redraw() {
161    if let Some(node_id) = CURRENT_DRAW_NODE.with(Cell::get) {
162        schedule_draw_repass(node_id);
163    }
164    request_render_invalidation();
165}
166
167pub(crate) fn clear_draw_observations_for_node(node_id: NodeId) {
168    with_draw_observer(|observer| {
169        observer.clear_if(|scope| {
170            scope
171                .downcast_ref::<DrawObservationScope>()
172                .is_some_and(|scope| scope.node_id == node_id)
173        });
174    });
175}
176
177/// Removes draw observations whose owners are absent from the retained scene.
178pub fn prune_draw_observations_to_nodes(retained: &HashSet<NodeId>) {
179    with_draw_observer(|observer| {
180        observer.clear_if(|scope| {
181            scope
182                .downcast_ref::<DrawObservationScope>()
183                .is_some_and(|scope| !retained.contains(&scope.node_id))
184        });
185    });
186}
187
188impl RenderState {
189    fn new_with_density(density: f32) -> Self {
190        Self {
191            layout_repasses: RefCell::new(DirtyNodeSet::new()),
192            measure_repasses: RefCell::new(DirtyNodeSet::new()),
193            draw_repasses: RefCell::new(DirtyNodeSet::new()),
194            layer_property_repasses: RefCell::new(DirtyNodeSet::new()),
195            modifier_slice_repasses: RefCell::new(DirtyNodeSet::new()),
196            geometry_scene_nodes: RefCell::new(DirtyNodeSet::new()),
197            moved_scene_nodes: RefCell::new(DirtyNodeSet::new()),
198            render_invalidated: Cell::new(false),
199            pointer_invalidated: Cell::new(false),
200            focus_invalidated: Cell::new(false),
201            layout_invalidated: Cell::new(false),
202            density_bits: Cell::new(normalize_density(density).to_bits()),
203            font_scale: Cell::new(crate::font_scale::FontScaleCurve::linear(1.0)),
204            font_scale_identity: Cell::new(true),
205            font_scale_fingerprint: Cell::new(
206                crate::font_scale::FontScaleCurve::linear(1.0).fingerprint(),
207            ),
208        }
209    }
210}
211
212std::thread_local! {
213    static NEXT_APP_CONTEXT_ID: Cell<u64> = const { Cell::new(1) };
214    static CURRENT_APP_CONTEXT: RefCell<Vec<Weak<AppContext>>> = const { RefCell::new(Vec::new()) };
215    static APP_CONTEXTS: RefCell<HashMap<AppContextId, Weak<AppContext>>> = RefCell::new(HashMap::new());
216}
217
218fn next_app_context_id() -> AppContextId {
219    NEXT_APP_CONTEXT_ID.with(|next| {
220        let id = next.get();
221        next.set(id.wrapping_add(1));
222        AppContextId(id)
223    })
224}
225
226#[doc(hidden)]
227pub struct AppContextScope;
228
229impl Drop for AppContextScope {
230    fn drop(&mut self) {
231        CURRENT_APP_CONTEXT.with(|stack| {
232            stack.borrow_mut().pop();
233        });
234    }
235}
236
237impl AppContext {
238    pub fn new() -> Rc<Self> {
239        Self::new_with_density(1.0)
240    }
241
242    pub fn new_with_density(density: f32) -> Rc<Self> {
243        let context = Rc::new(Self {
244            id: next_app_context_id(),
245            self_weak: RefCell::new(Weak::new()),
246            state: RenderState::new_with_density(density),
247            draw_observer: new_draw_observer(),
248            text: crate::text::measure::TextService::new(),
249            layout_frame_arena: RefCell::new(crate::layout::FrameLayoutArena::default()),
250            layout_cache_epoch: Cell::new(1),
251            layout_cache_floor: Cell::new(0),
252            last_fling_velocity_bits: Cell::new(0.0f32.to_bits()),
253            scroll_motion_contexts: crate::scroll::ScrollMotionContextStore::new(),
254            lazy_prefetch: crate::lazy_prefetch::LazyPrefetchState::new(),
255            layout_node_registry: crate::widgets::nodes::layout_node::LayoutNodeRegistryState::new(
256            ),
257            pointer_dispatch: crate::pointer_dispatch::PointerDispatchState::new(),
258            mouse_input: crate::mouse_input::MouseInputQueue::default(),
259            focus_dispatch: crate::focus_dispatch::FocusInvalidationState::new(),
260            modal: crate::modal::ModalState::new(),
261            modal_nodes: crate::modal_nodes::ModalNodes::default(),
262            semantics_layout: crate::semantics_layout_log::SemanticsLayoutLog::default(),
263            hosted_popups: crate::widgets::popup::HostedPopupRegistries::default(),
264            semantics_dispatch: crate::semantics_dispatch::SemanticsInvalidationState::new(),
265            cursor_animation: crate::cursor_animation::CursorAnimationState::new(),
266            text_field_focus: crate::text_field_focus::TextFieldFocusState::new(),
267            text_input_session: crate::text_input_session::PlatformTextInputState::new(),
268            clipboard_session: crate::clipboard_session::ClipboardSessionState::new(),
269            pointer_icon: crate::pointer_icon_session::PointerIconState::new(),
270            pointer_input_tasks: crate::modifier::pointer_input::PointerInputTaskRegistry::new(),
271            modifier_chain_trace: RefCell::new(None),
272            window_roots: crate::modifier::WindowRootRegistry::default(),
273            drag_and_drop: crate::modifier::DragAndDropState::default(),
274        });
275        *context.self_weak.borrow_mut() = Rc::downgrade(&context);
276        APP_CONTEXTS.with(|contexts| {
277            contexts
278                .borrow_mut()
279                .insert(context.id, Rc::downgrade(&context));
280        });
281        context
282    }
283
284    pub fn enter<R>(self: &Rc<Self>, block: impl FnOnce() -> R) -> R {
285        let _scope = self.enter_scope();
286        block()
287    }
288
289    pub(crate) fn id(&self) -> AppContextId {
290        self.id
291    }
292
293    pub(crate) fn mouse_input(&self) -> &crate::mouse_input::MouseInputQueue {
294        &self.mouse_input
295    }
296
297    /// Number of coalesced mouse movements waiting for host dispatch.
298    #[doc(hidden)]
299    pub fn pending_mouse_moves(&self) -> usize {
300        self.mouse_input.len()
301    }
302
303    /// Takes the oldest queued mouse movement, retaining the queue's storage.
304    #[doc(hidden)]
305    pub fn take_mouse_move(&self) -> Option<crate::mouse_input::MouseMoveRequest> {
306        self.mouse_input.take()
307    }
308
309    /// The window roots attached in this context.
310    pub fn window_roots(&self) -> &crate::modifier::WindowRootRegistry {
311        &self.window_roots
312    }
313
314    /// The drag and drop transfer and targets of this context.
315    pub fn drag_and_drop(&self) -> &crate::modifier::DragAndDropState {
316        &self.drag_and_drop
317    }
318
319    #[doc(hidden)]
320    pub fn enter_scope(self: &Rc<Self>) -> AppContextScope {
321        CURRENT_APP_CONTEXT.with(|stack| {
322            stack.borrow_mut().push(Rc::downgrade(self));
323        });
324        AppContextScope
325    }
326
327    pub fn set_text_measurer<M: crate::text::TextMeasurer>(&self, measurer: M) {
328        self.set_text_measurer_rc(Rc::new(measurer));
329    }
330
331    pub fn set_text_measurer_rc(&self, measurer: Rc<dyn crate::text::TextMeasurer>) {
332        self.text.set_measurer(measurer);
333        self.invalidate_layout_caches();
334        self.state.layout_invalidated.set(true);
335        self.state.render_invalidated.set(true);
336    }
337
338    fn invalidate_layout_caches(&self) {
339        let floor = self.layout_cache_epoch.get() + 1;
340        self.layout_cache_epoch.set(floor);
341        self.layout_cache_floor.set(floor);
342    }
343
344    #[doc(hidden)]
345    pub fn downgrade(&self) -> Weak<Self> {
346        self.self_weak.borrow().clone()
347    }
348}
349
350impl Drop for AppContext {
351    fn drop(&mut self) {
352        let id = self.id;
353        let _ = APP_CONTEXTS.try_with(|contexts| {
354            contexts.borrow_mut().remove(&id);
355        });
356    }
357}
358
359fn app_context_by_id(id: AppContextId) -> Option<Rc<AppContext>> {
360    APP_CONTEXTS
361        .try_with(|contexts| {
362            let context = contexts.borrow().get(&id).cloned()?;
363            let Some(context) = context.upgrade() else {
364                contexts.borrow_mut().remove(&id);
365                return None;
366            };
367            Some(context)
368        })
369        .ok()
370        .flatten()
371}
372
373#[cfg(test)]
374fn app_context_registry_entry_count() -> usize {
375    APP_CONTEXTS
376        .try_with(|contexts| contexts.borrow().len())
377        .unwrap_or_default()
378}
379
380pub(crate) fn with_app_context_by_id<R>(
381    id: AppContextId,
382    f: impl FnOnce(&Rc<AppContext>) -> R,
383) -> Option<R> {
384    app_context_by_id(id).map(|context| f(&context))
385}
386
387pub(crate) fn current_app_context_id() -> AppContextId {
388    require_current_app_context("app context identity access").id
389}
390
391pub(crate) fn with_layout_node_registry_by_app_context<R>(
392    id: AppContextId,
393    f: impl FnOnce(&crate::widgets::nodes::layout_node::LayoutNodeRegistryState) -> R,
394) -> Option<R> {
395    with_app_context_by_id(id, |context| f(&context.layout_node_registry))
396}
397
398pub(crate) fn enter_app_context_by_id<R>(id: AppContextId, f: impl FnOnce() -> R) -> Option<R> {
399    with_app_context_by_id(id, |context| context.enter(f))
400}
401
402pub(crate) fn current_app_context() -> Option<Rc<AppContext>> {
403    CURRENT_APP_CONTEXT
404        .try_with(|stack| {
405            let mut stack = stack.borrow_mut();
406            loop {
407                let context = stack.last()?;
408                if let Some(context) = context.upgrade() {
409                    return Some(context);
410                }
411                stack.pop();
412            }
413        })
414        .ok()
415        .flatten()
416}
417
418#[doc(hidden)]
419pub fn has_current_app_context() -> bool {
420    current_app_context().is_some()
421}
422
423pub(crate) fn require_current_app_context(operation: &str) -> Rc<AppContext> {
424    if let Some(context) = current_app_context() {
425        return context;
426    }
427    require_current_app_context_without_scope(operation)
428}
429
430fn require_current_app_context_without_scope(operation: &str) -> Rc<AppContext> {
431    panic!("{operation} requires an active AppContext")
432}
433
434fn with_render_state<R>(f: impl FnOnce(&RenderState) -> R) -> R {
435    let context = require_current_app_context("render state access");
436    f(&context.state)
437}
438
439fn normalize_density(density: f32) -> f32 {
440    if density.is_finite() && density > 0.0 {
441        density
442    } else {
443        1.0
444    }
445}
446
447fn normalize_font_scale(scale: f32) -> f32 {
448    if scale.is_finite() && scale > 0.0 {
449        scale.clamp(MIN_FONT_SCALE, MAX_FONT_SCALE)
450    } else {
451        1.0
452    }
453}
454
455/// Smallest system font scale honoured; below this, text stops being readable
456/// as text.
457pub const MIN_FONT_SCALE: f32 = 0.5;
458/// Largest system font scale honoured. Android's own accessibility slider tops
459/// out at 2.0.
460pub const MAX_FONT_SCALE: f32 = 3.0;
461
462pub(crate) fn with_text_measurer<R>(f: impl FnOnce(&dyn crate::text::TextMeasurer) -> R) -> R {
463    let context = require_current_app_context("text measurer access");
464    context.text.with_measurer(f)
465}
466
467/// Tells the current app context's text service, and its measurer, a
468/// layout pass starts. Outside an app context there is neither to tell.
469pub(crate) fn begin_text_layout_pass() {
470    if let Some(context) = current_app_context() {
471        context.text.begin_layout_pass();
472    }
473}
474
475pub(crate) fn with_text_service<R>(f: impl FnOnce(&crate::text::measure::TextService) -> R) -> R {
476    let context = require_current_app_context("text service access");
477    f(&context.text)
478}
479
480pub(crate) fn set_current_text_measurer(measurer: Rc<dyn crate::text::TextMeasurer>) {
481    let Some(context) = current_app_context() else {
482        panic!("set_text_measurer requires an active AppContext");
483    };
484    context.set_text_measurer_rc(measurer);
485}
486
487pub(crate) fn set_modifier_chain_trace(callback: Arc<ModifierChainTraceCallback>) -> AppContextId {
488    let context = require_current_app_context("modifier chain trace installation");
489    *context.modifier_chain_trace.borrow_mut() = Some(callback);
490    context.id
491}
492
493pub(crate) fn clear_modifier_chain_trace(context_id: AppContextId) {
494    let _ = with_app_context_by_id(context_id, |context| {
495        *context.modifier_chain_trace.borrow_mut() = None;
496    });
497}
498
499pub(crate) fn emit_modifier_chain_trace(nodes: &[crate::modifier::ModifierChainInspectorNode]) {
500    let Some(context) = current_app_context() else {
501        return;
502    };
503    let callback = context.modifier_chain_trace.borrow().clone();
504    if let Some(callback) = callback {
505        callback(nodes);
506    }
507}
508
509pub(crate) fn take_layout_frame_arena() -> crate::layout::FrameLayoutArena {
510    let context = require_current_app_context("layout frame arena access");
511
512    std::mem::take(&mut *context.layout_frame_arena.borrow_mut())
513}
514
515pub(crate) fn replace_layout_frame_arena(arena: crate::layout::FrameLayoutArena) {
516    let context = require_current_app_context("layout frame arena access");
517    *context.layout_frame_arena.borrow_mut() = arena;
518}
519
520pub(crate) fn invalidate_layout_cache_epoch() {
521    require_current_app_context("layout cache epoch access").invalidate_layout_caches();
522}
523
524pub(crate) fn next_layout_cache_epoch() -> u64 {
525    let context = require_current_app_context("layout cache epoch access");
526    let epoch = context.layout_cache_epoch.get();
527    context.layout_cache_epoch.set(epoch + 1);
528    epoch
529}
530
531pub(crate) fn current_layout_cache_epoch() -> u64 {
532    let context = require_current_app_context("layout cache epoch access");
533    context.layout_cache_epoch.get()
534}
535
536pub(crate) fn layout_cache_floor() -> u64 {
537    let context = require_current_app_context("layout cache epoch access");
538    context.layout_cache_floor.get()
539}
540
541pub(crate) fn record_last_fling_velocity(velocity: f32) {
542    if let Some(context) = current_app_context() {
543        context.last_fling_velocity_bits.set(velocity.to_bits());
544    }
545}
546
547#[doc(hidden)]
548pub fn debug_last_fling_velocity() -> f32 {
549    let context = require_current_app_context("fling velocity diagnostics access");
550    f32::from_bits(context.last_fling_velocity_bits.get())
551}
552
553#[doc(hidden)]
554pub fn debug_reset_last_fling_velocity() {
555    let context = require_current_app_context("fling velocity diagnostics access");
556    context.last_fling_velocity_bits.set(0.0f32.to_bits());
557}
558
559impl AppContext {
560    pub(crate) fn lazy_prefetch(&self) -> &crate::lazy_prefetch::LazyPrefetchState {
561        &self.lazy_prefetch
562    }
563}
564
565pub(crate) fn with_scroll_motion_context_store<R>(
566    f: impl FnOnce(&crate::scroll::ScrollMotionContextStore) -> R,
567) -> R {
568    let context = require_current_app_context("scroll motion context access");
569    f(&context.scroll_motion_contexts)
570}
571
572#[doc(hidden)]
573pub fn clear_transient_scroll_motion_contexts() {
574    let Some(context) = current_app_context() else {
575        return;
576    };
577    context.scroll_motion_contexts.clear_transient_after_frame();
578}
579
580#[cfg(test)]
581pub(crate) fn layout_frame_arena_placement_scratch_count() -> usize {
582    let context = require_current_app_context("layout frame arena access");
583
584    context
585        .layout_frame_arena
586        .borrow()
587        .available_placement_scratch_count()
588}
589
590pub(crate) fn with_layout_node_registry<R>(
591    f: impl FnOnce(&crate::widgets::nodes::layout_node::LayoutNodeRegistryState) -> R,
592) -> R {
593    let context = require_current_app_context("layout node registry access");
594    f(&context.layout_node_registry)
595}
596
597pub(crate) fn with_pointer_dispatch<R>(
598    f: impl FnOnce(&crate::pointer_dispatch::PointerDispatchState) -> R,
599) -> R {
600    let context = require_current_app_context("pointer dispatch access");
601    f(&context.pointer_dispatch)
602}
603
604pub(crate) fn with_focus_dispatch<R>(
605    f: impl FnOnce(&crate::focus_dispatch::FocusInvalidationState) -> R,
606) -> R {
607    let context = require_current_app_context("focus dispatch access");
608    f(&context.focus_dispatch)
609}
610
611pub(crate) fn with_focus_dispatch_by_app_context<R>(
612    id: AppContextId,
613    f: impl FnOnce(&crate::focus_dispatch::FocusInvalidationState) -> R,
614) -> Option<R> {
615    with_app_context_by_id(id, |context| context.enter(|| f(&context.focus_dispatch)))
616}
617
618pub(crate) fn with_modal_state<R>(f: impl FnOnce(&crate::modal::ModalState) -> R) -> R {
619    let context = require_current_app_context("modal state access");
620    f(&context.modal)
621}
622
623pub(crate) fn with_current_modal_nodes<R>(
624    f: impl FnOnce(&crate::modal_nodes::ModalNodes) -> R,
625) -> Option<R> {
626    current_app_context().map(|context| f(&context.modal_nodes))
627}
628
629pub(crate) fn with_current_semantics_layout_log<R>(
630    f: impl FnOnce(&crate::semantics_layout_log::SemanticsLayoutLog) -> R,
631) -> Option<R> {
632    current_app_context().map(|context| f(&context.semantics_layout))
633}
634
635pub(crate) fn with_hosted_popup_registries<R>(
636    f: impl FnOnce(&crate::widgets::popup::HostedPopupRegistries) -> R,
637) -> R {
638    let context = require_current_app_context("hosted popup registries access");
639    f(&context.hosted_popups)
640}
641
642pub(crate) fn with_semantics_dispatch<R>(
643    f: impl FnOnce(&crate::semantics_dispatch::SemanticsInvalidationState) -> R,
644) -> R {
645    let context = require_current_app_context("semantics dispatch access");
646    f(&context.semantics_dispatch)
647}
648
649pub(crate) fn with_semantics_dispatch_by_app_context(
650    id: AppContextId,
651    f: impl FnOnce(&crate::semantics_dispatch::SemanticsInvalidationState),
652) {
653    with_app_context_by_id(id, |context| f(&context.semantics_dispatch));
654}
655
656pub(crate) fn current_app_context_id_opt() -> Option<AppContextId> {
657    current_app_context().map(|context| context.id)
658}
659
660pub(crate) fn with_cursor_animation<R>(
661    f: impl FnOnce(&crate::cursor_animation::CursorAnimationState) -> R,
662) -> R {
663    let context = require_current_app_context("cursor animation access");
664    f(&context.cursor_animation)
665}
666
667pub(crate) fn with_text_field_focus<R>(
668    f: impl FnOnce(&crate::text_field_focus::TextFieldFocusState) -> R,
669) -> R {
670    let context = require_current_app_context("text field focus access");
671    f(&context.text_field_focus)
672}
673
674pub(crate) fn with_text_input_session<R>(
675    f: impl FnOnce(&crate::text_input_session::PlatformTextInputState) -> R,
676) -> R {
677    let context = require_current_app_context("platform text input session access");
678    f(&context.text_input_session)
679}
680
681pub(crate) fn with_clipboard_session<R>(
682    f: impl FnOnce(&crate::clipboard_session::ClipboardSessionState) -> R,
683) -> R {
684    let context = require_current_app_context("clipboard session access");
685    f(&context.clipboard_session)
686}
687
688pub(crate) fn with_pointer_icon_session<R>(
689    f: impl FnOnce(&crate::pointer_icon_session::PointerIconState) -> R,
690) -> R {
691    let context = require_current_app_context("pointer icon session access");
692    f(&context.pointer_icon)
693}
694
695pub(crate) fn register_pointer_input_task(
696    task_id: u64,
697    task: Rc<crate::modifier::pointer_input::PointerInputTaskInner>,
698) -> crate::modifier::pointer_input::PointerInputTaskOwner {
699    let context = require_current_app_context("pointer input task registration");
700    context.pointer_input_tasks.insert(task_id, task);
701    crate::modifier::pointer_input::PointerInputTaskOwner::App(context.id)
702}
703
704pub(crate) fn remove_pointer_input_task(
705    owner: crate::modifier::pointer_input::PointerInputTaskOwner,
706    task_id: u64,
707) {
708    match owner {
709        crate::modifier::pointer_input::PointerInputTaskOwner::App(context_id) => {
710            let _ = with_app_context_by_id(context_id, |context| {
711                context.pointer_input_tasks.remove(task_id);
712            });
713        }
714    }
715}
716
717pub(crate) fn request_pointer_input_task_poll(
718    owner: crate::modifier::pointer_input::PointerInputTaskOwner,
719    task_id: u64,
720) {
721    match owner {
722        crate::modifier::pointer_input::PointerInputTaskOwner::App(context_id) => {
723            let _ = with_app_context_by_id(context_id, |context| {
724                context.enter(|| {
725                    context.pointer_input_tasks.request_poll(task_id, owner);
726                });
727            });
728        }
729    }
730}
731
732fn with_draw_observer<R>(f: impl FnOnce(&SnapshotStateObserver) -> R) -> R {
733    let context = require_current_app_context("draw observer access");
734    f(&context.draw_observer)
735}
736
737struct DirtyNodeSet {
738    dirty_nodes: HashSet<NodeId>,
739}
740
741impl DirtyNodeSet {
742    fn new() -> Self {
743        Self {
744            dirty_nodes: HashSet::default(),
745        }
746    }
747
748    fn schedule_repass(&mut self, node_id: NodeId) {
749        self.dirty_nodes.insert(node_id);
750    }
751
752    fn has_pending_repass(&self) -> bool {
753        !self.dirty_nodes.is_empty()
754    }
755
756    fn take_dirty_nodes(&mut self) -> Vec<NodeId> {
757        self.dirty_nodes.drain().collect()
758    }
759
760    fn take_dirty_nodes_into(&mut self, output: &mut Vec<NodeId>) {
761        output.clear();
762        output.reserve(self.dirty_nodes.len());
763        output.extend(self.dirty_nodes.drain());
764    }
765
766    fn dirty_nodes_snapshot(&self) -> Vec<NodeId> {
767        let mut nodes = self.dirty_nodes.iter().copied().collect::<Vec<_>>();
768        nodes.sort_unstable();
769        nodes
770    }
771}
772
773/// Schedules a layout repass for a specific node.
774///
775/// **This is the preferred way to invalidate layout for local changes** (e.g., scroll, single-node mutations).
776///
777/// The app shell will call `take_layout_repass_nodes()` and bubble dirty flags up the tree
778/// via `bubble_layout_dirty`. This gives you **O(subtree) performance** - only the affected
779/// subtree is remeasured, and layout caches for other parts of the app remain valid.
780///
781/// A scoped repass never invalidates the whole tree, and it never cancels a global
782/// invalidation requested for the same frame.
783///
784/// # For Global Invalidation
785///
786/// For rare global events (window resize, global scale changes), use `request_layout_invalidation()` instead.
787#[track_caller]
788pub fn schedule_layout_repass(node_id: NodeId) {
789    if layout_repass_schedule_diagnostics_enabled_for(node_id) {
790        let caller = std::panic::Location::caller();
791        log::warn!(
792            "[layout-repass-schedule] node={} caller={}:{}:{}",
793            node_id,
794            caller.file(),
795            caller.line(),
796            caller.column()
797        );
798    }
799    with_render_state(|state| {
800        state.layout_repasses.borrow_mut().schedule_repass(node_id);
801    });
802    request_render_invalidation();
803}
804
805#[derive(Clone, Copy)]
806enum LayoutRepassScheduleDiag {
807    Disabled,
808    All,
809    Node(NodeId),
810}
811
812fn layout_repass_schedule_diagnostics_enabled_for(node_id: NodeId) -> bool {
813    static MODE: std::sync::OnceLock<LayoutRepassScheduleDiag> = std::sync::OnceLock::new();
814    match *MODE.get_or_init(|| {
815        let Some(value) = std::env::var_os("CRANPOSE_LAYOUT_REPASS_SCHEDULE_DIAG") else {
816            return LayoutRepassScheduleDiag::Disabled;
817        };
818        if value == "all" {
819            return LayoutRepassScheduleDiag::All;
820        }
821        value.to_string_lossy().parse::<NodeId>().map_or(
822            LayoutRepassScheduleDiag::Disabled,
823            LayoutRepassScheduleDiag::Node,
824        )
825    }) {
826        LayoutRepassScheduleDiag::Disabled => false,
827        LayoutRepassScheduleDiag::All => true,
828        LayoutRepassScheduleDiag::Node(target) => target == node_id,
829    }
830}
831
832pub(crate) fn schedule_modifier_slices_repass(node_id: NodeId) {
833    with_render_state(|state| {
834        state
835            .modifier_slice_repasses
836            .borrow_mut()
837            .schedule_repass(node_id);
838    });
839    schedule_draw_repass(node_id);
840}
841
842/// Schedules a draw-only repass for a specific node.
843///
844/// This ensures draw/pointer data stays in sync when modifier updates do not
845/// require a layout pass (e.g., draw-only modifier changes).
846pub fn schedule_draw_repass(node_id: NodeId) {
847    let context = require_current_app_context("render state access");
848    schedule_draw_repass_in_context(&context, node_id);
849}
850
851fn schedule_draw_repass_for_app_context(context_id: AppContextId, node_id: NodeId) {
852    let _ = with_app_context_by_id(context_id, |context| {
853        schedule_draw_repass_in_context(context, node_id);
854    });
855}
856
857fn schedule_draw_repass_in_context(context: &AppContext, node_id: NodeId) {
858    context
859        .state
860        .draw_repasses
861        .borrow_mut()
862        .schedule_repass(node_id);
863    context.state.render_invalidated.set(true);
864}
865
866fn schedule_layer_property_repass_for_app_context(context_id: AppContextId, node_id: NodeId) {
867    let _ = with_app_context_by_id(context_id, |context| {
868        schedule_layer_property_repass_in_context(context, node_id);
869    });
870}
871
872fn schedule_layer_property_repass_in_context(context: &AppContext, node_id: NodeId) {
873    context
874        .state
875        .layer_property_repasses
876        .borrow_mut()
877        .schedule_repass(node_id);
878    context.state.render_invalidated.set(true);
879}
880
881/// Returns true if any draw repasses are pending.
882pub fn has_pending_draw_repasses() -> bool {
883    with_render_state(|state| state.draw_repasses.borrow_mut().has_pending_repass())
884}
885
886/// Takes all pending draw repass node IDs.
887pub fn take_draw_repass_nodes() -> Vec<NodeId> {
888    with_render_state(|state| state.draw_repasses.borrow_mut().take_dirty_nodes())
889}
890
891/// Takes pending draw repasses into reusable caller-owned storage.
892///
893/// ```
894/// let context = cranpose_ui::AppContext::new();
895/// context.enter(|| {
896///     let mut nodes = Vec::new();
897///     cranpose_ui::take_draw_repass_nodes_into(&mut nodes);
898///     assert!(nodes.is_empty());
899/// });
900/// ```
901pub fn take_draw_repass_nodes_into(output: &mut Vec<NodeId>) {
902    with_render_state(|state| {
903        state
904            .draw_repasses
905            .borrow_mut()
906            .take_dirty_nodes_into(output);
907    });
908}
909
910/// Returns true if graphics-layer property updates are pending.
911#[doc(hidden)]
912pub fn has_pending_layer_property_repasses() -> bool {
913    with_render_state(|state| {
914        state
915            .layer_property_repasses
916            .borrow_mut()
917            .has_pending_repass()
918    })
919}
920
921/// Takes pending graphics-layer property updates into reusable storage.
922#[doc(hidden)]
923pub fn take_layer_property_repass_nodes_into(output: &mut Vec<NodeId>) {
924    with_render_state(|state| {
925        state
926            .layer_property_repasses
927            .borrow_mut()
928            .take_dirty_nodes_into(output);
929    });
930}
931
932/// Returns true if any layout repasses are pending.
933pub fn has_pending_layout_repasses() -> bool {
934    with_render_state(|state| state.layout_repasses.borrow_mut().has_pending_repass())
935}
936
937/// Returns a stable snapshot of pending layout repass node IDs without consuming them.
938pub fn pending_layout_repass_nodes_snapshot() -> Vec<NodeId> {
939    with_render_state(|state| state.layout_repasses.borrow_mut().dirty_nodes_snapshot())
940}
941
942/// Takes all pending layout repass node IDs.
943///
944/// The caller should iterate over these and call `bubble_layout_dirty` for each.
945pub fn take_layout_repass_nodes() -> Vec<NodeId> {
946    with_render_state(|state| state.layout_repasses.borrow_mut().take_dirty_nodes())
947}
948
949/// Schedules a scoped re-*measure* of `node_id` on the next frame.
950///
951/// Like [`schedule_layout_repass`], but processing bubbles *measure* dirtiness
952/// (not just layout/placement) up the tree, so the node and its ancestors are
953/// re-measured. Use this when a node's own measured size changes off a frame
954/// callback (e.g. a row collapsing after a swipe dismiss): a plain layout
955/// repass would leave the node's `needs_measure` flag unset, and an enclosing
956/// `LazyColumn` would reuse its cached, full-height item slot.
957pub fn schedule_measure_repass(node_id: NodeId) {
958    with_render_state(|state| {
959        state.measure_repasses.borrow_mut().schedule_repass(node_id);
960    });
961    request_render_invalidation();
962}
963
964/// Returns true if any measure repasses are pending.
965pub fn has_pending_measure_repasses() -> bool {
966    with_render_state(|state| state.measure_repasses.borrow_mut().has_pending_repass())
967}
968
969/// Returns a stable snapshot of pending measure repass node IDs without
970/// consuming them.
971///
972/// The layout pass takes these ids to bubble measure dirtiness; the scene phase
973/// needs the same ids *before* that happens, to scope its graph update to the
974/// subtree that moved. Without the snapshot a measure repass reaches the scene
975/// phase as "something changed, but nothing says where", which is
976/// indistinguishable from a full invalidation.
977pub fn pending_measure_repass_nodes_snapshot() -> Vec<NodeId> {
978    with_render_state(|state| state.measure_repasses.borrow_mut().dirty_nodes_snapshot())
979}
980
981/// Takes all pending measure repass node IDs.
982///
983/// The caller should iterate over these and call `bubble_measure_dirty` for each.
984pub fn take_measure_repass_nodes() -> Vec<NodeId> {
985    with_render_state(|state| state.measure_repasses.borrow_mut().take_dirty_nodes())
986}
987
988pub(crate) fn take_modifier_slice_repass_nodes() -> Vec<NodeId> {
989    with_render_state(|state| {
990        state
991            .modifier_slice_repasses
992            .borrow_mut()
993            .take_dirty_nodes()
994    })
995}
996
997pub(crate) fn record_geometry_scene_node(node_id: NodeId) {
998    with_render_state(|state| {
999        state
1000            .geometry_scene_nodes
1001            .borrow_mut()
1002            .schedule_repass(node_id);
1003    });
1004}
1005
1006pub(crate) fn record_moved_scene_node(node_id: NodeId) {
1007    with_render_state(|state| {
1008        state
1009            .moved_scene_nodes
1010            .borrow_mut()
1011            .schedule_repass(node_id);
1012    });
1013}
1014
1015/// The nodes whose geometry a layout pass changed, by what the scene redoes
1016/// for them.
1017#[derive(Clone, Debug, Default, PartialEq, Eq)]
1018pub struct GeometrySceneNodes {
1019    /// Nodes that changed size or were placed anew: the scene draws them
1020    /// again.
1021    pub reshaped: Vec<NodeId>,
1022    /// Nodes that moved in their parent: the scene moves what it drew for
1023    /// them, unless they are also reshaped or drawn again.
1024    pub moved: Vec<NodeId>,
1025}
1026
1027impl GeometrySceneNodes {
1028    /// Whether the pass changed no node's geometry.
1029    pub fn is_empty(&self) -> bool {
1030        self.reshaped.is_empty() && self.moved.is_empty()
1031    }
1032}
1033
1034/// Takes the nodes whose geometry the last layout pass actually changed.
1035///
1036/// The scene phase merges these into its scoped update scope. Consuming them
1037/// is mandatory whenever layout ran: geometry recorded by one pass is
1038/// meaningless to the next.
1039pub fn take_geometry_scene_nodes() -> GeometrySceneNodes {
1040    let mut nodes = GeometrySceneNodes::default();
1041    take_geometry_scene_nodes_into(&mut nodes);
1042    nodes
1043}
1044
1045/// Takes changed geometry nodes into reusable caller-owned storage.
1046///
1047/// ```
1048/// let context = cranpose_ui::AppContext::new();
1049/// context.enter(|| {
1050///     let mut nodes = cranpose_ui::GeometrySceneNodes::default();
1051///     cranpose_ui::take_geometry_scene_nodes_into(&mut nodes);
1052///     assert!(nodes.is_empty());
1053/// });
1054/// ```
1055pub fn take_geometry_scene_nodes_into(output: &mut GeometrySceneNodes) {
1056    with_render_state(|state| {
1057        state
1058            .geometry_scene_nodes
1059            .borrow_mut()
1060            .take_dirty_nodes_into(&mut output.reshaped);
1061        state
1062            .moved_scene_nodes
1063            .borrow_mut()
1064            .take_dirty_nodes_into(&mut output.moved);
1065    });
1066}
1067
1068/// Returns the current density scale factor (logical px per dp).
1069pub fn current_density() -> f32 {
1070    with_render_state(|state| f32::from_bits(state.density_bits.get()))
1071}
1072
1073/// Updates the current density scale factor.
1074///
1075/// This triggers a global layout invalidation when the value changes because
1076/// density impacts layout, text measurement, and input thresholds.
1077pub fn set_density(density: f32) {
1078    let normalized = normalize_density(density);
1079    let new_bits = normalized.to_bits();
1080    with_render_state(|state| {
1081        let old_bits = state.density_bits.replace(new_bits);
1082        if old_bits != new_bits {
1083            state.layout_invalidated.set(true);
1084        }
1085    });
1086}
1087
1088/// Returns the system font scale — the multiplier the user chose in the
1089/// platform's font-size setting, `1.0` when they left it alone.
1090///
1091/// This is the setting `Sp` is defined against, so text follows it while
1092/// everything measured in `Dp` does not. A platform that does not report one
1093/// leaves it at `1.0`.
1094///
1095/// It is the number to *report*, not the number to multiply by: what a size in
1096/// `Sp` comes to is [`scale_sp`], and on Android 14 and up the two are not the
1097/// same arithmetic. See [`crate::font_scale`].
1098pub fn current_font_scale() -> f32 {
1099    current_font_scale_curve().scale()
1100}
1101
1102/// Returns the conversion the platform performs for a size in `Sp`.
1103///
1104/// The setting is not a multiplier on every platform — see
1105/// [`crate::font_scale`] — so this, and not [`current_font_scale`], is what a
1106/// size in `Sp` is resolved through. The scalar remains the thing to *report*.
1107pub fn current_font_scale_curve() -> crate::font_scale::FontScaleCurve {
1108    with_render_state(|state| state.font_scale.get())
1109}
1110
1111/// The running app's font scale curve, or `None` when it leaves every size as
1112/// it is, which the caller then need not read.
1113pub(crate) fn current_scaling_font_scale_curve() -> Option<crate::font_scale::FontScaleCurve> {
1114    with_render_state(|state| (!state.font_scale_identity.get()).then(|| state.font_scale.get()))
1115}
1116
1117/// What a prepared text layout is valid for: the text service's generation
1118/// and the font scale curve's fingerprint, read in one app context access.
1119pub(crate) fn text_layout_stamp() -> (u64, u32) {
1120    let context = require_current_app_context("text layout stamp access");
1121    (
1122        context.text.generation(),
1123        context.state.font_scale_fingerprint.get(),
1124    )
1125}
1126
1127/// A size in scale-independent pixels, in dp, through the running app's curve.
1128pub fn scale_sp(sp: f32) -> f32 {
1129    current_font_scale_curve().sp_to_dp(sp)
1130}
1131
1132/// Updates the system font scale, taking it as a plain multiplier.
1133///
1134/// Hosts call this when the platform reports the setting, and again whenever it
1135/// changes while the app is running — on Android that is a configuration
1136/// change, which arrives without the process restarting. Like density it
1137/// invalidates layout, because every `Sp` size on screen has just changed.
1138///
1139/// A host whose platform converts `Sp` through a table of its own calls
1140/// [`set_font_scale_curve`] instead.
1141pub fn set_font_scale(scale: f32) {
1142    set_font_scale_curve(crate::font_scale::FontScaleCurve::linear(scale));
1143}
1144
1145/// Updates the system font scale and the conversion behind it.
1146///
1147/// Hosts that can read the platform's real `Sp` conversion call this instead of
1148/// [`set_font_scale`], which is the same thing with no table behind it. A curve
1149/// whose scale is not a value a platform could report is refused the same way a
1150/// bare scalar is, and refusing it drops the table with it: knots sampled at a
1151/// scale that was rejected describe a conversion the app is not going to use.
1152pub fn set_font_scale_curve(curve: crate::font_scale::FontScaleCurve) {
1153    let normalized = normalize_font_scale(curve.scale());
1154    let curve = if (normalized - curve.scale()).abs() <= f32::EPSILON {
1155        curve
1156    } else {
1157        crate::font_scale::FontScaleCurve::linear(normalized)
1158    };
1159    with_render_state(|state| {
1160        if state.font_scale.get() != curve {
1161            state.font_scale.set(curve);
1162            state.font_scale_identity.set(curve.is_identity());
1163            state.font_scale_fingerprint.set(curve.fingerprint());
1164            state.layout_invalidated.set(true);
1165        }
1166    });
1167}
1168
1169/// Requests that the renderer rebuild the current scene.
1170pub fn request_render_invalidation() {
1171    with_render_state(|state| state.render_invalidated.set(true));
1172}
1173
1174/// Returns true if a render invalidation was pending and clears the flag.
1175pub fn take_render_invalidation() -> bool {
1176    with_render_state(|state| state.render_invalidated.replace(false))
1177}
1178
1179/// Returns true if a render invalidation is pending without clearing it.
1180pub fn peek_render_invalidation() -> bool {
1181    with_render_state(|state| state.render_invalidated.get())
1182}
1183
1184/// Requests a new pointer-input pass without touching layout or draw dirties.
1185pub fn request_pointer_invalidation() {
1186    with_render_state(|state| state.pointer_invalidated.set(true));
1187}
1188
1189/// Returns true if a pointer invalidation was pending and clears the flag.
1190pub fn take_pointer_invalidation() -> bool {
1191    with_render_state(|state| state.pointer_invalidated.replace(false))
1192}
1193
1194/// Returns true if a pointer invalidation is pending without clearing it.
1195pub fn peek_pointer_invalidation() -> bool {
1196    with_render_state(|state| state.pointer_invalidated.get())
1197}
1198
1199/// Requests a focus recomposition without affecting layout/draw dirties.
1200pub fn request_focus_invalidation() {
1201    with_render_state(|state| state.focus_invalidated.set(true));
1202}
1203
1204/// Returns true if a focus invalidation was pending and clears the flag.
1205pub fn take_focus_invalidation() -> bool {
1206    with_render_state(|state| state.focus_invalidated.replace(false))
1207}
1208
1209/// Returns true if a focus invalidation is pending without clearing it.
1210pub fn peek_focus_invalidation() -> bool {
1211    with_render_state(|state| state.focus_invalidated.get())
1212}
1213
1214/// Requests a **global** layout re-run.
1215///
1216/// # ⚠️ WARNING: Extremely Expensive - O(entire app size)
1217///
1218/// This triggers internal cache invalidation that forces **every node** in the app
1219/// to re-measure, even if nothing changed. This is a performance footgun!
1220///
1221/// ## Valid Use Cases (rare!)
1222///
1223/// Only use this for **true global changes** that affect layout computation everywhere:
1224/// - Window/viewport resize
1225/// - Global font scale or density changes
1226/// - System-wide theme changes that affect layout
1227/// - Debug toggles that change layout behavior globally
1228///
1229/// ## For Local Changes - DO NOT USE THIS
1230///
1231/// **If you're invalidating layout for scroll, a single widget update, or any local change,
1232/// you MUST use the scoped repass mechanism instead:**
1233///
1234/// ```text
1235/// cranpose_ui::schedule_layout_repass(node_id);
1236/// ```
1237///
1238/// Scoped repasses give you O(subtree) performance instead of O(app), and they don't
1239/// invalidate caches across the entire app.
1240pub fn request_layout_invalidation() {
1241    with_render_state(|state| state.layout_invalidated.set(true));
1242}
1243
1244/// Returns true if a layout invalidation was pending and clears the flag.
1245pub fn take_layout_invalidation() -> bool {
1246    with_render_state(|state| state.layout_invalidated.replace(false))
1247}
1248
1249/// Returns true if a layout invalidation is pending without clearing it.
1250pub fn peek_layout_invalidation() -> bool {
1251    with_render_state(|state| state.layout_invalidated.get())
1252}
1253
1254#[cfg(any(test, feature = "test-helpers"))]
1255#[doc(hidden)]
1256pub fn reset_render_state_for_tests() {
1257    let _ = take_draw_repass_nodes();
1258    let mut layer_nodes = Vec::new();
1259    take_layer_property_repass_nodes_into(&mut layer_nodes);
1260    let _ = take_layout_repass_nodes();
1261    let _ = take_modifier_slice_repass_nodes();
1262    let _ = take_render_invalidation();
1263    let _ = take_pointer_invalidation();
1264    let _ = take_focus_invalidation();
1265    let _ = take_layout_invalidation();
1266    debug_reset_last_fling_velocity();
1267    set_density(1.0);
1268    set_font_scale(1.0);
1269    let _ = take_layout_invalidation();
1270}
1271
1272#[cfg(test)]
1273pub(crate) struct TestAppContextScope {
1274    _scope: AppContextScope,
1275    _context: Rc<AppContext>,
1276}
1277
1278#[cfg(test)]
1279pub(crate) fn app_context_test_scope() -> TestAppContextScope {
1280    let context = AppContext::new();
1281    let scope = context.enter_scope();
1282    context.enter(reset_render_state_for_tests);
1283    TestAppContextScope {
1284        _scope: scope,
1285        _context: context,
1286    }
1287}
1288
1289#[cfg(test)]
1290pub(crate) struct RenderStateTestGuard {
1291    _app_scope: TestAppContextScope,
1292    _lock: std::sync::MutexGuard<'static, ()>,
1293}
1294
1295#[cfg(test)]
1296pub(crate) fn render_state_test_guard() -> RenderStateTestGuard {
1297    static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1298    let lock = match TEST_LOCK.get_or_init(|| Mutex::new(())).lock() {
1299        Ok(guard) => guard,
1300        Err(poisoned) => poisoned.into_inner(),
1301    };
1302    RenderStateTestGuard {
1303        _app_scope: app_context_test_scope(),
1304        _lock: lock,
1305    }
1306}
1307
1308#[cfg(test)]
1309#[path = "tests/render_state_tests.rs"]
1310mod tests;