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