Skip to main content

cranpose_ui/
render_state.rs

1use cranpose_core::{current_runtime_handle, NodeId, SnapshotStateObserver};
2use std::collections::HashSet;
3use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
4use std::sync::Mutex;
5#[cfg(not(any(test, feature = "test-helpers")))]
6use std::sync::OnceLock;
7#[cfg(test)]
8use std::sync::OnceLock;
9
10struct RenderState {
11    layout_repasses: Mutex<LayoutRepassManager>,
12    draw_repasses: Mutex<DrawRepassManager>,
13    modifier_slice_repasses: Mutex<LayoutRepassManager>,
14    render_invalidated: AtomicBool,
15    pointer_invalidated: AtomicBool,
16    focus_invalidated: AtomicBool,
17    layout_invalidated: AtomicBool,
18    density_bits: AtomicU32,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub(crate) struct DrawObservationScope {
23    node_id: NodeId,
24    command_index: usize,
25}
26
27impl DrawObservationScope {
28    pub(crate) fn new(node_id: NodeId, command_index: usize) -> Self {
29        Self {
30            node_id,
31            command_index,
32        }
33    }
34}
35
36std::thread_local! {
37    static DRAW_OBSERVER: SnapshotStateObserver = {
38        let observer = SnapshotStateObserver::new(|callback| {
39            if let Some(runtime) = current_runtime_handle() {
40                runtime.enqueue_ui_task(callback);
41            } else {
42                callback();
43            }
44        });
45        observer.start();
46        observer
47    };
48}
49
50pub(crate) fn observe_draw_reads<R>(scope: DrawObservationScope, block: impl FnOnce() -> R) -> R {
51    DRAW_OBSERVER.with(|observer| {
52        observer.observe_reads(
53            scope,
54            |scope| {
55                schedule_draw_repass(scope.node_id);
56            },
57            block,
58        )
59    })
60}
61
62pub(crate) fn clear_draw_observations_for_node(node_id: NodeId) {
63    DRAW_OBSERVER.with(|observer| {
64        observer.clear_if(|scope| {
65            scope
66                .downcast_ref::<DrawObservationScope>()
67                .is_some_and(|scope| scope.node_id == node_id)
68        });
69    });
70}
71
72impl RenderState {
73    fn new() -> Self {
74        Self {
75            layout_repasses: Mutex::new(LayoutRepassManager::new()),
76            draw_repasses: Mutex::new(DrawRepassManager::new()),
77            modifier_slice_repasses: Mutex::new(LayoutRepassManager::new()),
78            render_invalidated: AtomicBool::new(false),
79            pointer_invalidated: AtomicBool::new(false),
80            focus_invalidated: AtomicBool::new(false),
81            layout_invalidated: AtomicBool::new(false),
82            density_bits: AtomicU32::new(f32::to_bits(1.0)),
83        }
84    }
85}
86
87#[cfg(not(any(test, feature = "test-helpers")))]
88fn with_render_state<R>(f: impl FnOnce(&RenderState) -> R) -> R {
89    static STATE: OnceLock<RenderState> = OnceLock::new();
90    f(STATE.get_or_init(RenderState::new))
91}
92
93#[cfg(any(test, feature = "test-helpers"))]
94fn with_render_state<R>(f: impl FnOnce(&RenderState) -> R) -> R {
95    std::thread_local! {
96        static STATE: RenderState = RenderState::new();
97    }
98    STATE.with(f)
99}
100
101/// Manages scoped layout invalidations for specific nodes.
102///
103/// Similar to PointerDispatchManager, this tracks which specific nodes
104/// need layout invalidation rather than forcing a global invalidation.
105struct LayoutRepassManager {
106    dirty_nodes: HashSet<NodeId>,
107}
108
109impl LayoutRepassManager {
110    fn new() -> Self {
111        Self {
112            dirty_nodes: HashSet::new(),
113        }
114    }
115
116    fn schedule_repass(&mut self, node_id: NodeId) {
117        self.dirty_nodes.insert(node_id);
118    }
119
120    fn has_pending_repass(&self) -> bool {
121        !self.dirty_nodes.is_empty()
122    }
123
124    fn take_dirty_nodes(&mut self) -> Vec<NodeId> {
125        self.dirty_nodes.drain().collect()
126    }
127}
128
129/// Tracks draw-only invalidations so render data can be refreshed without layout.
130struct DrawRepassManager {
131    dirty_nodes: HashSet<NodeId>,
132}
133
134impl DrawRepassManager {
135    fn new() -> Self {
136        Self {
137            dirty_nodes: HashSet::new(),
138        }
139    }
140
141    fn schedule_repass(&mut self, node_id: NodeId) {
142        self.dirty_nodes.insert(node_id);
143    }
144
145    fn has_pending_repass(&self) -> bool {
146        !self.dirty_nodes.is_empty()
147    }
148
149    fn take_dirty_nodes(&mut self) -> Vec<NodeId> {
150        self.dirty_nodes.drain().collect()
151    }
152}
153
154/// Schedules a layout repass for a specific node.
155///
156/// **This is the preferred way to invalidate layout for local changes** (e.g., scroll, single-node mutations).
157///
158/// The app shell will call `take_layout_repass_nodes()` and bubble dirty flags up the tree
159/// via `bubble_layout_dirty`. This gives you **O(subtree) performance** - only the affected
160/// subtree is remeasured, and layout caches for other parts of the app remain valid.
161///
162/// # Implementation Note
163///
164/// This sets the `LAYOUT_INVALIDATED` flag to signal the app shell there's work to do,
165/// but the flag alone does NOT trigger global cache invalidation. The app shell checks
166/// `take_layout_repass_nodes()` first and processes scoped repasses. Global cache invalidation
167/// only happens if the flag is set AND there are no scoped repasses (a rare fallback case).
168///
169/// # For Global Invalidation
170///
171/// For rare global events (window resize, global scale changes), use `request_layout_invalidation()` instead.
172pub fn schedule_layout_repass(node_id: NodeId) {
173    with_render_state(|state| {
174        state
175            .layout_repasses
176            .lock()
177            .expect("layout repass manager poisoned")
178            .schedule_repass(node_id);
179        state.layout_invalidated.store(true, Ordering::Relaxed);
180    });
181    // Set the layout-invalidated flag so the app shell knows to process repasses.
182    // The app shell will check take_layout_repass_nodes() first (scoped path),
183    // and only falls back to global invalidation if the flag is set without any repass nodes.
184    // Also request render invalidation so the frame is actually drawn.
185    // Without this, programmatic scrolls (e.g., scroll_to_item) wouldn't trigger a redraw
186    // until the next user interaction caused a frame request.
187    request_render_invalidation();
188}
189
190pub(crate) fn schedule_modifier_slices_repass(node_id: NodeId) {
191    with_render_state(|state| {
192        state
193            .modifier_slice_repasses
194            .lock()
195            .expect("modifier slice repass manager poisoned")
196            .schedule_repass(node_id);
197    });
198    schedule_layout_repass(node_id);
199}
200
201/// Schedules a draw-only repass for a specific node.
202///
203/// This ensures draw/pointer data stays in sync when modifier updates do not
204/// require a layout pass (e.g., draw-only modifier changes).
205pub fn schedule_draw_repass(node_id: NodeId) {
206    with_render_state(|state| {
207        state
208            .draw_repasses
209            .lock()
210            .expect("draw repass manager poisoned")
211            .schedule_repass(node_id);
212    });
213    request_render_invalidation();
214}
215
216/// Returns true if any draw repasses are pending.
217pub fn has_pending_draw_repasses() -> bool {
218    with_render_state(|state| {
219        state
220            .draw_repasses
221            .lock()
222            .expect("draw repass manager poisoned")
223            .has_pending_repass()
224    })
225}
226
227/// Takes all pending draw repass node IDs.
228pub fn take_draw_repass_nodes() -> Vec<NodeId> {
229    with_render_state(|state| {
230        state
231            .draw_repasses
232            .lock()
233            .expect("draw repass manager poisoned")
234            .take_dirty_nodes()
235    })
236}
237
238/// Returns true if any layout repasses are pending.
239pub fn has_pending_layout_repasses() -> bool {
240    with_render_state(|state| {
241        state
242            .layout_repasses
243            .lock()
244            .expect("layout repass manager poisoned")
245            .has_pending_repass()
246    })
247}
248
249/// Takes all pending layout repass node IDs.
250///
251/// The caller should iterate over these and call `bubble_layout_dirty` for each.
252pub fn take_layout_repass_nodes() -> Vec<NodeId> {
253    with_render_state(|state| {
254        state
255            .layout_repasses
256            .lock()
257            .expect("layout repass manager poisoned")
258            .take_dirty_nodes()
259    })
260}
261
262pub(crate) fn take_modifier_slice_repass_nodes() -> Vec<NodeId> {
263    with_render_state(|state| {
264        state
265            .modifier_slice_repasses
266            .lock()
267            .expect("modifier slice repass manager poisoned")
268            .take_dirty_nodes()
269    })
270}
271
272/// Returns the current density scale factor (logical px per dp).
273pub fn current_density() -> f32 {
274    with_render_state(|state| f32::from_bits(state.density_bits.load(Ordering::Relaxed)))
275}
276
277/// Updates the current density scale factor.
278///
279/// This triggers a global layout invalidation when the value changes because
280/// density impacts layout, text measurement, and input thresholds.
281pub fn set_density(density: f32) {
282    let normalized = if density.is_finite() && density > 0.0 {
283        density
284    } else {
285        1.0
286    };
287    let new_bits = normalized.to_bits();
288    with_render_state(|state| {
289        let old_bits = state.density_bits.swap(new_bits, Ordering::Relaxed);
290        if old_bits != new_bits {
291            state.layout_invalidated.store(true, Ordering::Relaxed);
292        }
293    });
294}
295
296/// Requests that the renderer rebuild the current scene.
297pub fn request_render_invalidation() {
298    with_render_state(|state| state.render_invalidated.store(true, Ordering::Relaxed));
299}
300
301/// Returns true if a render invalidation was pending and clears the flag.
302pub fn take_render_invalidation() -> bool {
303    with_render_state(|state| state.render_invalidated.swap(false, Ordering::Relaxed))
304}
305
306/// Returns true if a render invalidation is pending without clearing it.
307pub fn peek_render_invalidation() -> bool {
308    with_render_state(|state| state.render_invalidated.load(Ordering::Relaxed))
309}
310
311/// Requests a new pointer-input pass without touching layout or draw dirties.
312pub fn request_pointer_invalidation() {
313    with_render_state(|state| state.pointer_invalidated.store(true, Ordering::Relaxed));
314}
315
316/// Returns true if a pointer invalidation was pending and clears the flag.
317pub fn take_pointer_invalidation() -> bool {
318    with_render_state(|state| state.pointer_invalidated.swap(false, Ordering::Relaxed))
319}
320
321/// Returns true if a pointer invalidation is pending without clearing it.
322pub fn peek_pointer_invalidation() -> bool {
323    with_render_state(|state| state.pointer_invalidated.load(Ordering::Relaxed))
324}
325
326/// Requests a focus recomposition without affecting layout/draw dirties.
327pub fn request_focus_invalidation() {
328    with_render_state(|state| state.focus_invalidated.store(true, Ordering::Relaxed));
329}
330
331/// Returns true if a focus invalidation was pending and clears the flag.
332pub fn take_focus_invalidation() -> bool {
333    with_render_state(|state| state.focus_invalidated.swap(false, Ordering::Relaxed))
334}
335
336/// Returns true if a focus invalidation is pending without clearing it.
337pub fn peek_focus_invalidation() -> bool {
338    with_render_state(|state| state.focus_invalidated.load(Ordering::Relaxed))
339}
340
341/// Requests a **global** layout re-run.
342///
343/// # ⚠️ WARNING: Extremely Expensive - O(entire app size)
344///
345/// This triggers internal cache invalidation that forces **every node** in the app
346/// to re-measure, even if nothing changed. This is a performance footgun!
347///
348/// ## Valid Use Cases (rare!)
349///
350/// Only use this for **true global changes** that affect layout computation everywhere:
351/// - Window/viewport resize
352/// - Global font scale or density changes
353/// - System-wide theme changes that affect layout
354/// - Debug toggles that change layout behavior globally
355///
356/// ## For Local Changes - DO NOT USE THIS
357///
358/// **If you're invalidating layout for scroll, a single widget update, or any local change,
359/// you MUST use the scoped repass mechanism instead:**
360///
361/// ```text
362/// cranpose_ui::schedule_layout_repass(node_id);
363/// ```
364///
365/// Scoped repasses give you O(subtree) performance instead of O(app), and they don't
366/// invalidate caches across the entire app.
367pub fn request_layout_invalidation() {
368    with_render_state(|state| state.layout_invalidated.store(true, Ordering::Relaxed));
369}
370
371/// Returns true if a layout invalidation was pending and clears the flag.
372pub fn take_layout_invalidation() -> bool {
373    with_render_state(|state| state.layout_invalidated.swap(false, Ordering::Relaxed))
374}
375
376/// Returns true if a layout invalidation is pending without clearing it.
377pub fn peek_layout_invalidation() -> bool {
378    with_render_state(|state| state.layout_invalidated.load(Ordering::Relaxed))
379}
380
381#[cfg(any(test, feature = "test-helpers"))]
382#[doc(hidden)]
383pub fn reset_render_state_for_tests() {
384    let _ = take_draw_repass_nodes();
385    let _ = take_layout_repass_nodes();
386    let _ = take_modifier_slice_repass_nodes();
387    let _ = take_render_invalidation();
388    let _ = take_pointer_invalidation();
389    let _ = take_focus_invalidation();
390    let _ = take_layout_invalidation();
391    set_density(1.0);
392    let _ = take_layout_invalidation();
393}
394
395#[cfg(test)]
396pub(crate) fn render_state_test_guard() -> std::sync::MutexGuard<'static, ()> {
397    static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
398    match TEST_LOCK.get_or_init(|| Mutex::new(())).lock() {
399        Ok(guard) => guard,
400        Err(poisoned) => poisoned.into_inner(),
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::sync::{mpsc, Arc};
408
409    #[test]
410    fn invalidation_flags_are_shared_across_threads() {
411        let state = Arc::new(RenderState::new());
412        let (tx, rx) = mpsc::channel();
413        let worker_state = Arc::clone(&state);
414
415        let handle = std::thread::spawn(move || {
416            worker_state
417                .render_invalidated
418                .store(true, Ordering::Relaxed);
419            worker_state
420                .pointer_invalidated
421                .store(true, Ordering::Relaxed);
422            worker_state
423                .focus_invalidated
424                .store(true, Ordering::Relaxed);
425            worker_state
426                .layout_invalidated
427                .store(true, Ordering::Relaxed);
428            worker_state
429                .density_bits
430                .store(f32::to_bits(2.0), Ordering::Relaxed);
431            tx.send(()).expect("signal invalidation setup");
432
433            f32::from_bits(worker_state.density_bits.load(Ordering::Relaxed))
434        });
435
436        rx.recv().expect("wait for worker invalidation setup");
437        assert!(state.render_invalidated.load(Ordering::Relaxed));
438        assert!(state.pointer_invalidated.load(Ordering::Relaxed));
439        assert!(state.focus_invalidated.load(Ordering::Relaxed));
440        assert!(state.layout_invalidated.load(Ordering::Relaxed));
441        assert_eq!(
442            f32::from_bits(state.density_bits.load(Ordering::Relaxed)),
443            2.0
444        );
445        assert!(state.render_invalidated.swap(false, Ordering::Relaxed));
446        assert!(state.pointer_invalidated.swap(false, Ordering::Relaxed));
447        assert!(state.focus_invalidated.swap(false, Ordering::Relaxed));
448        assert!(state.layout_invalidated.swap(false, Ordering::Relaxed));
449
450        let density = handle.join().expect("worker invalidation snapshot");
451        assert_eq!(density, 2.0);
452        assert!(!state.render_invalidated.load(Ordering::Relaxed));
453        assert!(!state.pointer_invalidated.load(Ordering::Relaxed));
454        assert!(!state.focus_invalidated.load(Ordering::Relaxed));
455        assert!(!state.layout_invalidated.load(Ordering::Relaxed));
456    }
457}