Skip to main content

azul_layout/managers/
cursor.rs

1//! Text cursor management
2//!
3//! Manages text cursor position and state for contenteditable elements.
4//!
5//! # Cursor Lifecycle
6//!
7//! The cursor is automatically managed in response to focus changes:
8//!
9//! 1. **Focus lands on contenteditable node**: Cursor initialized at end of text
10//! 2. **Focus moves to non-editable node**: Cursor automatically cleared
11//! 3. **Focus clears entirely**: Cursor automatically cleared
12//!
13//! ## Automatic Cursor Initialization
14//!
15//! When focus is set to a contenteditable node via `FocusManager::set_focused_node()`,
16//! the event system (in `window.rs`) checks if the node is contenteditable and calls
17//! `CursorManager::initialize_cursor_at_end()` to place the cursor at the end of the text.
18//!
19//! This happens for:
20//!
21//! - User clicks on contenteditable element
22//! - Tab navigation to contenteditable element
23//! - Programmatic focus via `AccessibilityAction::Focus`
24//! - Focus from screen reader commands
25//!
26//! ## Cursor Blinking
27//!
28//! The cursor blinks at ~530ms intervals when a contenteditable element has focus.
29//! Blinking is managed by a system timer (`CURSOR_BLINK_TIMER_ID`) that:
30//!
31//! - Starts when focus lands on a contenteditable element
32//! - Stops when focus moves away
33//! - Resets (cursor becomes visible) on any user input (keyboard, mouse)
34//! - After ~530ms of no input, the cursor toggles visibility
35//!
36//! ## Integration with Text Layout
37//!
38//! The cursor manager uses the `TextLayoutCache` to determine:
39//!
40//! - Total number of grapheme clusters in the text
41//! - Position of the last grapheme cluster (for cursor-at-end)
42//! - Bounding rectangles for scroll-into-view
43//!
44//! ## Scroll-Into-View
45//!
46//! When a cursor is set, the system automatically checks if it's visible in the
47//! viewport. If not, it uses the `ScrollManager` to scroll the minimum amount
48//! needed to bring the cursor into view.
49//!
50//! ## Multi-Cursor Support
51//!
52//! While the core `TextCursor` type supports multi-cursor editing (used in
53//! `text3::edit`), the `CursorManager` currently manages a single cursor for
54//! accessibility and user interaction. Multi-cursor scenarios are handled at
55//! the `SelectionManager` level with multiple `Selection::Cursor` items.
56
57use azul_core::{
58    dom::{DomId, NodeId},
59    selection::{CursorAffinity, GraphemeClusterId, TextCursor},
60    task::Instant,
61};
62
63/// Default cursor blink interval in milliseconds
64pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
65
66/// Manager for text cursor position and rendering
67#[derive(Debug, Clone)]
68pub struct CursorManager {
69    /// Current cursor position (if any)
70    pub cursor: Option<TextCursor>,
71    /// DOM and node where the cursor is located
72    pub cursor_location: Option<CursorLocation>,
73    /// Whether the cursor is currently visible (toggled by blink timer)
74    pub is_visible: bool,
75    /// Timestamp of the last user input event (keyboard, mouse click in text)
76    /// Used to determine whether to blink or stay solid while typing
77    pub last_input_time: Option<Instant>,
78    /// Whether the cursor blink timer is currently active
79    pub blink_timer_active: bool,
80}
81
82/// Location of a cursor within the DOM
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct CursorLocation {
85    pub dom_id: DomId,
86    pub node_id: NodeId,
87    /// Stable key for the contenteditable element.
88    /// This is used to re-match the cursor to the correct node after DOM updates.
89    /// Calculated using `azul_core::diff::calculate_contenteditable_key`.
90    /// If 0, the key has not been calculated yet.
91    pub contenteditable_key: u64,
92}
93
94impl CursorLocation {
95    /// Create a new CursorLocation with just dom_id and node_id (key = 0)
96    pub fn new(dom_id: DomId, node_id: NodeId) -> Self {
97        Self {
98            dom_id,
99            node_id,
100            contenteditable_key: 0,
101        }
102    }
103    
104    /// Create a new CursorLocation with a stable key
105    pub fn with_key(dom_id: DomId, node_id: NodeId, contenteditable_key: u64) -> Self {
106        Self {
107            dom_id,
108            node_id,
109            contenteditable_key,
110        }
111    }
112}
113
114impl PartialEq for CursorManager {
115    fn eq(&self, other: &Self) -> bool {
116        // Ignore is_visible and last_input_time for equality - they're transient state
117        self.cursor == other.cursor && self.cursor_location == other.cursor_location
118    }
119}
120
121impl Default for CursorManager {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl CursorManager {
128    /// Create a new cursor manager with no cursor
129    pub fn new() -> Self {
130        Self {
131            cursor: None,
132            cursor_location: None,
133            is_visible: false,
134            last_input_time: None,
135            blink_timer_active: false,
136        }
137    }
138
139    /// Get the current cursor position
140    pub fn get_cursor(&self) -> Option<&TextCursor> {
141        self.cursor.as_ref()
142    }
143
144    /// Get the current cursor location
145    pub fn get_cursor_location(&self) -> Option<&CursorLocation> {
146        self.cursor_location.as_ref()
147    }
148
149    /// Set the cursor position manually
150    ///
151    /// This is used for programmatic cursor positioning. For automatic
152    /// initialization when focusing a contenteditable element, use
153    /// `initialize_cursor_at_end()`.
154    pub fn set_cursor(&mut self, cursor: Option<TextCursor>, location: Option<CursorLocation>) {
155        self.cursor = cursor;
156        self.cursor_location = location;
157        // Make cursor visible when set
158        if cursor.is_some() {
159            self.is_visible = true;
160        }
161    }
162    
163    /// Set the cursor position with timestamp for blink reset
164    pub fn set_cursor_with_time(&mut self, cursor: Option<TextCursor>, location: Option<CursorLocation>, now: Instant) {
165        self.cursor = cursor;
166        self.cursor_location = location;
167        if cursor.is_some() {
168            self.is_visible = true;
169            self.last_input_time = Some(now);
170        }
171    }
172
173    /// Clear the cursor
174    ///
175    /// This is automatically called when focus moves to a non-editable node
176    /// or when focus is cleared entirely.
177    pub fn clear(&mut self) {
178        self.cursor = None;
179        self.cursor_location = None;
180        self.is_visible = false;
181        self.last_input_time = None;
182        self.blink_timer_active = false;
183    }
184
185    /// Check if there is an active cursor
186    pub fn has_cursor(&self) -> bool {
187        self.cursor.is_some()
188    }
189    
190    /// Check if the cursor should be drawn (has cursor AND is visible)
191    pub fn should_draw_cursor(&self) -> bool {
192        self.cursor.is_some() && self.is_visible
193    }
194    
195    /// Reset the blink state on user input
196    ///
197    /// This makes the cursor visible and records the input time.
198    /// The blink timer will keep the cursor visible until `CURSOR_BLINK_INTERVAL_MS`
199    /// has passed since this time.
200    pub fn reset_blink_on_input(&mut self, now: Instant) {
201        self.is_visible = true;
202        self.last_input_time = Some(now);
203    }
204    
205    /// Toggle cursor visibility (called by blink timer)
206    ///
207    /// Returns the new visibility state.
208    pub fn toggle_visibility(&mut self) -> bool {
209        self.is_visible = !self.is_visible;
210        self.is_visible
211    }
212    
213    /// Set cursor visibility directly
214    pub fn set_visibility(&mut self, visible: bool) {
215        self.is_visible = visible;
216    }
217    
218    /// Check if enough time has passed since last input to start blinking
219    ///
220    /// Returns true if the cursor should blink (toggle visibility),
221    /// false if it should stay solid (user is actively typing).
222    pub fn should_blink(&self, now: &Instant) -> bool {
223        use azul_core::task::{Duration, SystemTimeDiff};
224        
225        match &self.last_input_time {
226            Some(last_input) => {
227                let elapsed = now.duration_since(last_input);
228                let blink_interval = Duration::System(SystemTimeDiff::from_millis(CURSOR_BLINK_INTERVAL_MS));
229                // If elapsed time is greater than blink interval, allow blinking
230                elapsed.greater_than(&blink_interval)
231            }
232            None => true, // No input recorded, allow blinking
233        }
234    }
235    
236    /// Mark the blink timer as active
237    pub fn set_blink_timer_active(&mut self, active: bool) {
238        self.blink_timer_active = active;
239    }
240    
241    /// Check if the blink timer is active
242    pub fn is_blink_timer_active(&self) -> bool {
243        self.blink_timer_active
244    }
245
246    /// Initialize cursor at the end of the text in the given node
247    ///
248    /// This is called automatically when focus lands on a contenteditable element.
249    /// It queries the text layout to find the position of the last grapheme
250    /// cluster and places the cursor there.
251    ///
252    /// # Returns
253    ///
254    /// `true` if cursor was successfully initialized, `false` if the node has no text
255    /// or text layout is not available.
256    pub fn initialize_cursor_at_end(
257        &mut self,
258        dom_id: DomId,
259        node_id: NodeId,
260        text_layout: Option<&alloc::sync::Arc<crate::text3::cache::UnifiedLayout>>,
261    ) -> bool {
262        // Get the text layout for this node
263        let Some(layout) = text_layout else {
264            // No text layout - set cursor at start
265            self.cursor = Some(TextCursor {
266                cluster_id: GraphemeClusterId {
267                    source_run: 0,
268                    start_byte_in_run: 0,
269                },
270                affinity: CursorAffinity::Trailing,
271            });
272            self.cursor_location = Some(CursorLocation::new(dom_id, node_id));
273            self.is_visible = true; // Make cursor visible immediately
274            return true;
275        };
276
277        // Find the last grapheme cluster in items
278        let mut last_cluster_id: Option<GraphemeClusterId> = None;
279
280        // Iterate through all items to find the last cluster
281        for item in layout.items.iter().rev() {
282            if let crate::text3::cache::ShapedItem::Cluster(cluster) = &item.item {
283                last_cluster_id = Some(cluster.source_cluster_id);
284                break;
285            }
286        }
287
288        // Set cursor at the end of the text
289        self.cursor = Some(TextCursor {
290            cluster_id: last_cluster_id.unwrap_or(GraphemeClusterId {
291                source_run: 0,
292                start_byte_in_run: 0,
293            }),
294            affinity: CursorAffinity::Trailing,
295        });
296
297        self.cursor_location = Some(CursorLocation::new(dom_id, node_id));
298        self.is_visible = true; // Make cursor visible immediately
299
300        true
301    }
302
303    /// Initialize cursor at the start of the text in the given node
304    ///
305    /// This can be used for specific navigation scenarios (e.g., Ctrl+Home).
306    pub fn initialize_cursor_at_start(&mut self, dom_id: DomId, node_id: NodeId) {
307        self.cursor = Some(TextCursor {
308            cluster_id: GraphemeClusterId {
309                source_run: 0,
310                start_byte_in_run: 0,
311            },
312            affinity: CursorAffinity::Trailing,
313        });
314
315        self.cursor_location = Some(CursorLocation::new(dom_id, node_id));
316    }
317
318    /// Move the cursor to a specific position
319    ///
320    /// This is used by text editing operations and keyboard navigation.
321    pub fn move_cursor_to(&mut self, cursor: TextCursor, dom_id: DomId, node_id: NodeId) {
322        self.cursor = Some(cursor);
323        self.cursor_location = Some(CursorLocation::new(dom_id, node_id));
324    }
325
326    /// Check if the cursor is in a specific node
327    pub fn is_cursor_in_node(&self, dom_id: DomId, node_id: NodeId) -> bool {
328        self.cursor_location
329            .as_ref()
330            .map(|loc| loc.dom_id == dom_id && loc.node_id == node_id)
331            .unwrap_or(false)
332    }
333    
334    /// Get the DomNodeId where the cursor is located (for cross-frame tracking)
335    pub fn get_cursor_node(&self) -> Option<azul_core::dom::DomNodeId> {
336        self.cursor_location.as_ref().map(|loc| {
337            azul_core::dom::DomNodeId {
338                dom: loc.dom_id,
339                node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(loc.node_id)),
340            }
341        })
342    }
343    
344    /// Update the NodeId for the cursor location (after DOM reconciliation)
345    ///
346    /// This is called when the DOM is regenerated and NodeIds change.
347    /// The cursor position within the text is preserved.
348    pub fn update_node_id(&mut self, new_node: azul_core::dom::DomNodeId) {
349        if let Some(ref mut loc) = self.cursor_location {
350            if let Some(new_id) = new_node.node.into_crate_internal() {
351                loc.dom_id = new_node.dom;
352                loc.node_id = new_id;
353            }
354        }
355    }
356    
357    /// Remap NodeIds after DOM reconciliation
358    ///
359    /// When the DOM is regenerated, NodeIds can change. This method updates
360    /// the cursor location to use the new NodeId based on the provided mapping.
361    pub fn remap_node_ids(
362        &mut self,
363        dom_id: DomId,
364        node_id_map: &std::collections::BTreeMap<NodeId, NodeId>,
365    ) {
366        if let Some(ref mut loc) = self.cursor_location {
367            if loc.dom_id == dom_id {
368                if let Some(&new_node_id) = node_id_map.get(&loc.node_id) {
369                    loc.node_id = new_node_id;
370                } else {
371                    // Node was removed, clear cursor location
372                    self.cursor_location = None;
373                }
374            }
375        }
376    }
377}