Skip to main content

cranpose_ui/
render_state.rs

1use cranpose_core::NodeId;
2use std::cell::RefCell;
3use std::collections::HashSet;
4use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
5
6thread_local! {
7    static LAYOUT_REPASS_MANAGER: RefCell<LayoutRepassManager> =
8        RefCell::new(LayoutRepassManager::new());
9    static DRAW_REPASS_MANAGER: RefCell<DrawRepassManager> =
10        RefCell::new(DrawRepassManager::new());
11}
12
13/// Manages scoped layout invalidations for specific nodes.
14///
15/// Similar to PointerDispatchManager, this tracks which specific nodes
16/// need layout invalidation rather than forcing a global invalidation.
17struct LayoutRepassManager {
18    dirty_nodes: HashSet<NodeId>,
19}
20
21impl LayoutRepassManager {
22    fn new() -> Self {
23        Self {
24            dirty_nodes: HashSet::new(),
25        }
26    }
27
28    fn schedule_repass(&mut self, node_id: NodeId) {
29        self.dirty_nodes.insert(node_id);
30    }
31
32    fn has_pending_repass(&self) -> bool {
33        !self.dirty_nodes.is_empty()
34    }
35
36    fn take_dirty_nodes(&mut self) -> Vec<NodeId> {
37        self.dirty_nodes.drain().collect()
38    }
39}
40
41/// Tracks draw-only invalidations so render data can be refreshed without layout.
42struct DrawRepassManager {
43    dirty_nodes: HashSet<NodeId>,
44}
45
46impl DrawRepassManager {
47    fn new() -> Self {
48        Self {
49            dirty_nodes: HashSet::new(),
50        }
51    }
52
53    fn schedule_repass(&mut self, node_id: NodeId) {
54        self.dirty_nodes.insert(node_id);
55    }
56
57    fn has_pending_repass(&self) -> bool {
58        !self.dirty_nodes.is_empty()
59    }
60
61    fn take_dirty_nodes(&mut self) -> Vec<NodeId> {
62        self.dirty_nodes.drain().collect()
63    }
64}
65
66/// Schedules a layout repass for a specific node.
67///
68/// **This is the preferred way to invalidate layout for local changes** (e.g., scroll, single-node mutations).
69///
70/// The app shell will call `take_layout_repass_nodes()` and bubble dirty flags up the tree
71/// via `bubble_layout_dirty`. This gives you **O(subtree) performance** - only the affected
72/// subtree is remeasured, and layout caches for other parts of the app remain valid.
73///
74/// # Implementation Note
75///
76/// This sets the `LAYOUT_INVALIDATED` flag to signal the app shell there's work to do,
77/// but the flag alone does NOT trigger global cache invalidation. The app shell checks
78/// `take_layout_repass_nodes()` first and processes scoped repasses. Global cache invalidation
79/// only happens if the flag is set AND there are no scoped repasses (a rare fallback case).
80///
81/// # For Global Invalidation
82///
83/// For rare global events (window resize, global scale changes), use `request_layout_invalidation()` instead.
84pub fn schedule_layout_repass(node_id: NodeId) {
85    LAYOUT_REPASS_MANAGER.with(|manager| {
86        manager.borrow_mut().schedule_repass(node_id);
87    });
88    // Set the global flag so the app shell knows to process repasses.
89    // The app shell will check take_layout_repass_nodes() first (scoped path),
90    // and only falls back to global invalidation if the flag is set without any repass nodes.
91    LAYOUT_INVALIDATED.store(true, Ordering::Relaxed);
92    // Also request render invalidation so the frame is actually drawn.
93    // Without this, programmatic scrolls (e.g., scroll_to_item) wouldn't trigger a redraw
94    // until the next user interaction caused a frame request.
95    request_render_invalidation();
96}
97
98/// Schedules a draw-only repass for a specific node.
99///
100/// This ensures draw/pointer data stays in sync when modifier updates do not
101/// require a layout pass (e.g., draw-only modifier changes).
102pub fn schedule_draw_repass(node_id: NodeId) {
103    DRAW_REPASS_MANAGER.with(|manager| {
104        manager.borrow_mut().schedule_repass(node_id);
105    });
106}
107
108/// Returns true if any draw repasses are pending.
109pub fn has_pending_draw_repasses() -> bool {
110    DRAW_REPASS_MANAGER.with(|manager| manager.borrow().has_pending_repass())
111}
112
113/// Takes all pending draw repass node IDs.
114pub fn take_draw_repass_nodes() -> Vec<NodeId> {
115    DRAW_REPASS_MANAGER.with(|manager| manager.borrow_mut().take_dirty_nodes())
116}
117
118/// Returns true if any layout repasses are pending.
119pub fn has_pending_layout_repasses() -> bool {
120    LAYOUT_REPASS_MANAGER.with(|manager| manager.borrow().has_pending_repass())
121}
122
123/// Takes all pending layout repass node IDs.
124///
125/// The caller should iterate over these and call `bubble_layout_dirty` for each.
126pub fn take_layout_repass_nodes() -> Vec<NodeId> {
127    LAYOUT_REPASS_MANAGER.with(|manager| manager.borrow_mut().take_dirty_nodes())
128}
129
130static RENDER_INVALIDATED: AtomicBool = AtomicBool::new(false);
131static POINTER_INVALIDATED: AtomicBool = AtomicBool::new(false);
132static FOCUS_INVALIDATED: AtomicBool = AtomicBool::new(false);
133static DENSITY_BITS: AtomicU32 = AtomicU32::new(f32::to_bits(1.0));
134
135/// Returns the current density scale factor (logical px per dp).
136pub fn current_density() -> f32 {
137    f32::from_bits(DENSITY_BITS.load(Ordering::Relaxed))
138}
139
140/// Updates the current density scale factor.
141///
142/// This triggers a global layout invalidation when the value changes because
143/// density impacts layout, text measurement, and input thresholds.
144pub fn set_density(density: f32) {
145    let normalized = if density.is_finite() && density > 0.0 {
146        density
147    } else {
148        1.0
149    };
150    let new_bits = normalized.to_bits();
151    let old_bits = DENSITY_BITS.swap(new_bits, Ordering::Relaxed);
152    if old_bits != new_bits {
153        request_layout_invalidation();
154    }
155}
156
157/// Requests that the renderer rebuild the current scene.
158pub fn request_render_invalidation() {
159    RENDER_INVALIDATED.store(true, Ordering::Relaxed);
160}
161
162/// Returns true if a render invalidation was pending and clears the flag.
163pub fn take_render_invalidation() -> bool {
164    RENDER_INVALIDATED.swap(false, Ordering::Relaxed)
165}
166
167/// Returns true if a render invalidation is pending without clearing it.
168pub fn peek_render_invalidation() -> bool {
169    RENDER_INVALIDATED.load(Ordering::Relaxed)
170}
171
172/// Requests a new pointer-input pass without touching layout or draw dirties.
173pub fn request_pointer_invalidation() {
174    POINTER_INVALIDATED.store(true, Ordering::Relaxed);
175}
176
177/// Returns true if a pointer invalidation was pending and clears the flag.
178pub fn take_pointer_invalidation() -> bool {
179    POINTER_INVALIDATED.swap(false, Ordering::Relaxed)
180}
181
182/// Returns true if a pointer invalidation is pending without clearing it.
183pub fn peek_pointer_invalidation() -> bool {
184    POINTER_INVALIDATED.load(Ordering::Relaxed)
185}
186
187/// Requests a focus recomposition without affecting layout/draw dirties.
188pub fn request_focus_invalidation() {
189    FOCUS_INVALIDATED.store(true, Ordering::Relaxed);
190}
191
192/// Returns true if a focus invalidation was pending and clears the flag.
193pub fn take_focus_invalidation() -> bool {
194    FOCUS_INVALIDATED.swap(false, Ordering::Relaxed)
195}
196
197/// Returns true if a focus invalidation is pending without clearing it.
198pub fn peek_focus_invalidation() -> bool {
199    FOCUS_INVALIDATED.load(Ordering::Relaxed)
200}
201
202static LAYOUT_INVALIDATED: AtomicBool = AtomicBool::new(false);
203
204/// Requests a **global** layout re-run.
205///
206/// # ⚠️ WARNING: Extremely Expensive - O(entire app size)
207///
208/// This triggers internal cache invalidation that forces **every node** in the app
209/// to re-measure, even if nothing changed. This is a performance footgun!
210///
211/// ## Valid Use Cases (rare!)
212///
213/// Only use this for **true global changes** that affect layout computation everywhere:
214/// - Window/viewport resize
215/// - Global font scale or density changes
216/// - System-wide theme changes that affect layout
217/// - Debug toggles that change layout behavior globally
218///
219/// ## For Local Changes - DO NOT USE THIS
220///
221/// **If you're invalidating layout for scroll, a single widget update, or any local change,
222/// you MUST use the scoped repass mechanism instead:**
223///
224/// ```text
225/// cranpose_ui::schedule_layout_repass(node_id);
226/// ```
227///
228/// Scoped repasses give you O(subtree) performance instead of O(app), and they don't
229/// invalidate caches across the entire app.
230pub fn request_layout_invalidation() {
231    LAYOUT_INVALIDATED.store(true, Ordering::Relaxed);
232}
233
234/// Returns true if a layout invalidation was pending and clears the flag.
235pub fn take_layout_invalidation() -> bool {
236    LAYOUT_INVALIDATED.swap(false, Ordering::Relaxed)
237}
238
239/// Returns true if a layout invalidation is pending without clearing it.
240pub fn peek_layout_invalidation() -> bool {
241    LAYOUT_INVALIDATED.load(Ordering::Relaxed)
242}