Skip to main content

cranpose_ui/
focus_dispatch.rs

1//! Focus invalidation manager for Cranpose.
2//!
3//! This module implements focus invalidation servicing that mirrors Jetpack Compose's
4//! `FocusInvalidationManager`. When focus modifiers change, they mark nodes for
5//! reprocessing without forcing layout/draw passes.
6
7use cranpose_core::NodeId;
8use std::cell::RefCell;
9use std::collections::HashSet;
10
11thread_local! {
12    static FOCUS_INVALIDATION_MANAGER: RefCell<FocusInvalidationManager> =
13        RefCell::new(FocusInvalidationManager::new());
14}
15
16/// Manages focus invalidations across the UI tree.
17///
18/// Similar to Kotlin's `FocusInvalidationManager`, this tracks which
19/// layout nodes need focus state reprocessing and provides hooks for
20/// the runtime to service those invalidations.
21struct FocusInvalidationManager {
22    dirty_nodes: HashSet<NodeId>,
23    is_processing: bool,
24    active_focus_target: Option<NodeId>,
25}
26
27impl FocusInvalidationManager {
28    fn new() -> Self {
29        Self {
30            dirty_nodes: HashSet::new(),
31            is_processing: false,
32            active_focus_target: None,
33        }
34    }
35
36    fn schedule_invalidation(&mut self, node_id: NodeId) {
37        self.dirty_nodes.insert(node_id);
38    }
39
40    fn has_pending_invalidation(&self) -> bool {
41        !self.dirty_nodes.is_empty()
42    }
43
44    fn set_active_focus_target(&mut self, node_id: Option<NodeId>) {
45        self.active_focus_target = node_id;
46    }
47
48    fn active_focus_target(&self) -> Option<NodeId> {
49        self.active_focus_target
50    }
51
52    fn process_invalidations<F>(&mut self, mut processor: F)
53    where
54        F: FnMut(NodeId),
55    {
56        if self.is_processing {
57            return;
58        }
59
60        self.is_processing = true;
61
62        // Process all dirty nodes
63        let nodes: Vec<NodeId> = self.dirty_nodes.drain().collect();
64        for node_id in nodes {
65            processor(node_id);
66        }
67
68        self.is_processing = false;
69    }
70
71    fn clear(&mut self) {
72        self.dirty_nodes.clear();
73    }
74}
75
76/// Schedules a focus invalidation for the specified node.
77///
78/// This is called automatically when focus modifiers invalidate
79/// and mirrors Kotlin's `FocusInvalidationManager.scheduleInvalidation`.
80pub fn schedule_focus_invalidation(node_id: NodeId) {
81    FOCUS_INVALIDATION_MANAGER.with(|manager| {
82        manager.borrow_mut().schedule_invalidation(node_id);
83    });
84}
85
86/// Returns true if any focus invalidations are pending.
87pub fn has_pending_focus_invalidations() -> bool {
88    FOCUS_INVALIDATION_MANAGER.with(|manager| manager.borrow().has_pending_invalidation())
89}
90
91/// Sets the currently active focus target.
92///
93/// This mirrors Kotlin's `FocusOwner.activeFocusTargetNode` and allows
94/// the focus system to track which node currently has focus.
95pub fn set_active_focus_target(node_id: Option<NodeId>) {
96    FOCUS_INVALIDATION_MANAGER.with(|manager| {
97        manager.borrow_mut().set_active_focus_target(node_id);
98    });
99}
100
101/// Returns the currently active focus target, if any.
102pub fn active_focus_target() -> Option<NodeId> {
103    FOCUS_INVALIDATION_MANAGER.with(|manager| manager.borrow().active_focus_target())
104}
105
106/// Processes all pending focus invalidations.
107///
108/// The host (e.g., app shell or layout engine) should call this after
109/// composition/layout to service focus invalidations without forcing
110/// measure/layout passes.
111pub fn process_focus_invalidations<F>(processor: F)
112where
113    F: FnMut(NodeId),
114{
115    FOCUS_INVALIDATION_MANAGER.with(|manager| {
116        manager.borrow_mut().process_invalidations(processor);
117    });
118}
119
120/// Clears all pending focus invalidations without processing them.
121pub fn clear_focus_invalidations() {
122    FOCUS_INVALIDATION_MANAGER.with(|manager| {
123        manager.borrow_mut().clear();
124    });
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn schedule_and_process_invalidations() {
133        clear_focus_invalidations();
134
135        let node1: NodeId = 1;
136        let node2: NodeId = 2;
137
138        schedule_focus_invalidation(node1);
139        schedule_focus_invalidation(node2);
140
141        assert!(has_pending_focus_invalidations());
142
143        let mut processed = Vec::new();
144        process_focus_invalidations(|node_id| {
145            processed.push(node_id);
146        });
147
148        assert_eq!(processed.len(), 2);
149        assert!(processed.contains(&node1));
150        assert!(processed.contains(&node2));
151        assert!(!has_pending_focus_invalidations());
152    }
153
154    #[test]
155    fn active_focus_target_tracking() {
156        set_active_focus_target(None);
157        assert_eq!(active_focus_target(), None);
158
159        let node: NodeId = 42;
160        set_active_focus_target(Some(node));
161        assert_eq!(active_focus_target(), Some(node));
162
163        set_active_focus_target(None);
164        assert_eq!(active_focus_target(), None);
165    }
166
167    #[test]
168    fn duplicate_invalidations_deduplicated() {
169        clear_focus_invalidations();
170
171        let node: NodeId = 42;
172        schedule_focus_invalidation(node);
173        schedule_focus_invalidation(node);
174        schedule_focus_invalidation(node);
175
176        let mut count = 0;
177        process_focus_invalidations(|_| {
178            count += 1;
179        });
180
181        assert_eq!(count, 1);
182    }
183}