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