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