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