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