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