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