Skip to main content

cranpose_ui/
render_state.rs

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