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