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