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}