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