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