Skip to main content

cranpose_ui/
render_state.rs

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