Skip to main content

cranpose_ui/
render_state.rs

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