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