Skip to main content

azul_layout/managers/
text_edit.rs

1//! Unified text editing manager
2//!
3//! Single source of truth for all text editing state. `MultiCursorState` is
4//! the primary cursor/selection system. `BlinkState` handles the caret blink
5//! animation. `SelectionManager` (in sibling module `selection`) handles
6//! non-editable drag-select only.
7//!
8//! Every mutation that affects visual output sets `display_list_dirty = true`,
9//! ensuring the display list is always regenerated.
10
11use azul_core::{
12    dom::{DomId, DomNodeId, NodeId},
13    selection::{MultiCursorState, Selection, TextCursor},
14    styled_dom::NodeHierarchyItemId,
15    task::Instant,
16};
17
18
19/// Default cursor blink interval in milliseconds
20pub const CURSOR_BLINK_INTERVAL_MS: u64 = 530;
21
22/// Cursor blink animation state.
23///
24/// Extracted from the old `CursorManager` so it can live independently
25/// on `TextEditManager` without coupling to cursor position.
26#[derive(Debug, Clone)]
27pub struct BlinkState {
28    /// Whether the cursor is currently visible (toggled by blink timer)
29    pub is_visible: bool,
30    /// Timestamp of the last user input event (keyboard, mouse click in text).
31    /// Used to determine whether to blink or stay solid while typing.
32    pub last_input_time: Option<Instant>,
33    /// Whether the cursor blink timer is currently active
34    pub blink_timer_active: bool,
35}
36
37impl Default for BlinkState {
38    fn default() -> Self {
39        Self {
40            is_visible: false,
41            last_input_time: None,
42            blink_timer_active: false,
43        }
44    }
45}
46
47impl BlinkState {
48    pub fn new() -> Self { Self::default() }
49
50    /// Reset blink on user input — cursor stays solid until blink interval elapses.
51    pub fn reset_blink_on_input(&mut self, now: Instant) {
52        self.is_visible = true;
53        self.last_input_time = Some(now);
54    }
55
56    /// Toggle cursor visibility (called by blink timer callback).
57    pub fn toggle_visibility(&mut self) -> bool {
58        self.is_visible = !self.is_visible;
59        self.is_visible
60    }
61
62    pub fn set_visibility(&mut self, visible: bool) {
63        self.is_visible = visible;
64    }
65
66    pub fn set_blink_timer_active(&mut self, active: bool) {
67        self.blink_timer_active = active;
68    }
69
70    pub fn is_blink_timer_active(&self) -> bool {
71        self.blink_timer_active
72    }
73
74    /// Check if enough time has passed since last input to start blinking.
75    pub fn should_blink(&self, now: &Instant) -> bool {
76        use azul_core::task::{Duration, SystemTimeDiff};
77        match &self.last_input_time {
78            Some(last_input) => {
79                let elapsed = now.duration_since(last_input);
80                let blink_interval = Duration::System(SystemTimeDiff::from_millis(CURSOR_BLINK_INTERVAL_MS));
81                elapsed.greater_than(&blink_interval)
82            }
83            None => true,
84        }
85    }
86
87    /// Clear all blink state (when editing ends).
88    pub fn clear(&mut self) {
89        self.is_visible = false;
90        self.last_input_time = None;
91        self.blink_timer_active = false;
92    }
93}
94
95/// Unified text editing manager.
96///
97/// `multi_cursor` is the single source of truth for cursor/selection positions.
98/// `blink` manages the caret blink animation.
99/// `SelectionManager` (sibling module) handles non-editable text drag-select.
100#[derive(Debug, Clone)]
101pub struct TextEditManager {
102    /// Multi-cursor state for contenteditable elements (Sublime Text style).
103    /// `Some` whenever a contenteditable element has focus.
104    /// Source of truth for `edit_text()` and display list painting.
105    pub multi_cursor: Option<MultiCursorState>,
106    /// Cursor blink animation state.
107    pub blink: BlinkState,
108    /// IME preedit (composition) text currently being composed.
109    /// Applies to the primary cursor only.
110    pub preedit_text: Option<String>,
111    /// Byte offset of cursor within preedit text (from IME), or -1 if unset.
112    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
113    pub preedit_cursor_begin: i32,
114    /// Byte offset of cursor end within preedit text (from IME), or -1 if unset.
115    /// Uses -1 sentinel (rather than `Option`) to match platform IME C API conventions.
116    pub preedit_cursor_end: i32,
117    /// Set to true by any mutation that changes visual output.
118    pub display_list_dirty: bool,
119}
120
121impl Default for TextEditManager {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127/// Only compares `multi_cursor` — blink state, preedit, and dirty flag are
128/// transient visual state that should not affect logical equality of the
129/// editing session.
130impl PartialEq for TextEditManager {
131    fn eq(&self, other: &Self) -> bool {
132        self.multi_cursor == other.multi_cursor
133    }
134}
135
136impl TextEditManager {
137    /// Create a new text edit manager with no active editing state
138    pub fn new() -> Self {
139        Self {
140            multi_cursor: None,
141            blink: BlinkState::new(),
142            preedit_text: None,
143            preedit_cursor_begin: -1,
144            preedit_cursor_end: -1,
145            display_list_dirty: false,
146        }
147    }
148
149    // === Dirty flag ===
150
151    /// Check and clear the display_list_dirty flag.
152    pub fn take_display_list_dirty(&mut self) -> bool {
153        let v = self.display_list_dirty;
154        self.display_list_dirty = false;
155        v
156    }
157
158    /// Mark that the display list needs regeneration.
159    pub fn mark_dirty(&mut self) {
160        self.display_list_dirty = true;
161    }
162
163    // === Editing lifecycle ===
164
165    /// Whether a contenteditable element is currently being edited.
166    pub fn has_active_editing(&self) -> bool {
167        self.multi_cursor.is_some()
168    }
169
170    /// Get the DomId of the node being edited.
171    pub fn get_editing_dom_id(&self) -> Option<DomId> {
172        self.multi_cursor.as_ref().map(|mc| mc.node_id.dom)
173    }
174
175    /// Get the NodeId of the node being edited.
176    pub fn get_editing_node_id(&self) -> Option<NodeId> {
177        self.multi_cursor.as_ref()
178            .and_then(|mc| mc.node_id.node.into_crate_internal())
179    }
180
181    /// Get the primary cursor position (last-added cursor).
182    pub fn get_primary_cursor(&self) -> Option<TextCursor> {
183        self.multi_cursor.as_ref().and_then(|mc| mc.get_primary_cursor())
184    }
185
186    /// Whether the cursor should be drawn (editing active AND blink visible).
187    pub fn should_draw_cursor(&self) -> bool {
188        self.has_active_editing() && self.blink.is_visible
189    }
190
191    /// Initialize editing for a newly focused contenteditable element.
192    ///
193    /// Creates a `MultiCursorState` with a single cursor, starts the blink,
194    /// and sets preedit to None.
195    pub fn initialize_editing(
196        &mut self,
197        cursor: TextCursor,
198        dom_id: DomId,
199        node_id: NodeId,
200        contenteditable_key: u64,
201    ) {
202        let dom_node_id = DomNodeId {
203            dom: dom_id,
204            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
205        };
206        self.multi_cursor = Some(MultiCursorState::new_with_cursor(
207            cursor,
208            dom_node_id,
209            contenteditable_key,
210        ));
211        self.blink.is_visible = true;
212        self.blink.last_input_time = None;
213        self.clear_preedit();
214        self.mark_dirty();
215    }
216
217    /// End editing (focus left the contenteditable element).
218    pub fn clear_editing(&mut self) {
219        self.multi_cursor = None;
220        self.blink.clear();
221        self.clear_preedit();
222        self.mark_dirty();
223    }
224
225    // === IME preedit ===
226
227    /// Set the IME preedit (composition) text.
228    pub fn set_preedit(&mut self, text: String, cursor_begin: i32, cursor_end: i32) {
229        self.preedit_text = if text.is_empty() { None } else { Some(text) };
230        self.preedit_cursor_begin = cursor_begin;
231        self.preedit_cursor_end = cursor_end;
232        self.mark_dirty();
233    }
234
235    /// Clear the IME preedit text (composition ended or cancelled).
236    pub fn clear_preedit(&mut self) {
237        self.preedit_text = None;
238        self.preedit_cursor_begin = -1;
239        self.preedit_cursor_end = -1;
240        self.mark_dirty();
241    }
242
243    // === Convenience for building cursor_locations ===
244
245    /// Build the Vec of cursor locations for LayoutContext.
246    ///
247    /// Returns all cursor positions from MultiCursorState, or empty if not editing.
248    pub fn build_cursor_locations(&self) -> Vec<(DomId, NodeId, TextCursor)> {
249        let Some(ref mc) = self.multi_cursor else {
250            return Vec::new();
251        };
252        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
253            return Vec::new();
254        };
255        mc.selections.iter().map(|s| {
256            let cursor = match &s.selection {
257                Selection::Cursor(c) => *c,
258                Selection::Range(r) => r.end,
259            };
260            (mc.node_id.dom, node_id, cursor)
261        }).collect()
262    }
263
264    /// Build a TextSelection map for the display list's `paint_selections`.
265    ///
266    /// Extracts Range selections from MultiCursorState into the format that
267    /// `LayoutContext.text_selections` expects: `BTreeMap<DomId, TextSelection>`.
268    /// The `affected_nodes` map uses the editing node's NodeId as key.
269    /// NOTE: only one range per node is supported — if multiple cursors have
270    /// range selections on the same node, later ranges overwrite earlier ones.
271    pub fn build_text_selections_map(&self) -> std::collections::BTreeMap<DomId, azul_core::selection::TextSelection> {
272        use azul_core::selection::{TextSelection, SelectionAnchor, SelectionFocus};
273        use azul_core::geom::LogicalRect;
274
275        let mut map = std::collections::BTreeMap::new();
276        let Some(ref mc) = self.multi_cursor else {
277            return map;
278        };
279        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
280            return map;
281        };
282
283        let mut affected_nodes = std::collections::BTreeMap::new();
284        let mut first_range: Option<azul_core::selection::SelectionRange> = None;
285        for sel in &mc.selections {
286            if let Selection::Range(range) = &sel.selection {
287                affected_nodes.insert(node_id, *range);
288                if first_range.is_none() {
289                    first_range = Some(*range);
290                }
291            }
292        }
293
294        if let Some(range) = first_range {
295            map.insert(mc.node_id.dom, TextSelection {
296                dom_id: mc.node_id.dom,
297                anchor: SelectionAnchor {
298                    ifc_root_node_id: node_id,
299                    cursor: range.start,
300                    char_bounds: LogicalRect::zero(),
301                    mouse_position: azul_core::geom::LogicalPosition::zero(),
302                },
303                focus: SelectionFocus {
304                    ifc_root_node_id: node_id,
305                    cursor: range.end,
306                    mouse_position: azul_core::geom::LogicalPosition::zero(),
307                },
308                affected_nodes,
309                is_forward: true,
310            });
311        }
312
313        map
314    }
315}