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