Skip to main content

cranpose_ui/
render_state.rs

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