Skip to main content

cranpose_ui/
render_state.rs

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