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

            
11
use 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
20
pub 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)]
27
pub 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

            
37
impl Default for BlinkState {
38
2321
    fn default() -> Self {
39
2321
        Self {
40
2321
            is_visible: false,
41
2321
            last_input_time: None,
42
2321
            blink_timer_active: false,
43
2321
        }
44
2321
    }
45
}
46

            
47
impl BlinkState {
48
2321
    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
7
    pub fn set_visibility(&mut self, visible: bool) {
63
7
        self.is_visible = visible;
64
7
    }
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)]
101
pub 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

            
121
impl 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.
130
impl PartialEq for TextEditManager {
131
    fn eq(&self, other: &Self) -> bool {
132
        self.multi_cursor == other.multi_cursor
133
    }
134
}
135

            
136
impl TextEditManager {
137
    /// Create a new text edit manager with no active editing state
138
2321
    pub fn new() -> Self {
139
2321
        Self {
140
2321
            multi_cursor: None,
141
2321
            blink: BlinkState::new(),
142
2321
            preedit_text: None,
143
2321
            preedit_cursor_begin: -1,
144
2321
            preedit_cursor_end: -1,
145
2321
            display_list_dirty: false,
146
2321
        }
147
2321
    }
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
490
    pub fn mark_dirty(&mut self) {
160
490
        self.display_list_dirty = true;
161
490
    }
162

            
163
    // === Editing lifecycle ===
164

            
165
    /// Whether a contenteditable element is currently being edited.
166
2765
    pub fn has_active_editing(&self) -> bool {
167
2765
        self.multi_cursor.is_some()
168
2765
    }
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
1435
    pub fn get_primary_cursor(&self) -> Option<TextCursor> {
183
1435
        self.multi_cursor.as_ref().and_then(|mc| mc.get_primary_cursor())
184
1435
    }
185

            
186
    /// Whether the cursor should be drawn (editing active AND blink visible).
187
2765
    pub fn should_draw_cursor(&self) -> bool {
188
2765
        self.has_active_editing() && self.blink.is_visible
189
2765
    }
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
245
    pub fn initialize_editing(
196
245
        &mut self,
197
245
        cursor: TextCursor,
198
245
        dom_id: DomId,
199
245
        node_id: NodeId,
200
245
        contenteditable_key: u64,
201
245
    ) {
202
245
        let dom_node_id = DomNodeId {
203
245
            dom: dom_id,
204
245
            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
205
245
        };
206
245
        self.multi_cursor = Some(MultiCursorState::new_with_cursor(
207
245
            cursor,
208
245
            dom_node_id,
209
245
            contenteditable_key,
210
245
        ));
211
245
        self.blink.is_visible = true;
212
245
        self.blink.last_input_time = None;
213
245
        self.clear_preedit();
214
245
        self.mark_dirty();
215
245
    }
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
245
    pub fn clear_preedit(&mut self) {
237
245
        self.preedit_text = None;
238
245
        self.preedit_cursor_begin = -1;
239
245
        self.preedit_cursor_end = -1;
240
245
        self.mark_dirty();
241
245
    }
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
2730
    pub fn build_cursor_locations(&self) -> Vec<(DomId, NodeId, TextCursor)> {
249
2730
        let Some(ref mc) = self.multi_cursor else {
250
2275
            return Vec::new();
251
        };
252
455
        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
253
            return Vec::new();
254
        };
255
455
        mc.selections.iter().map(|s| {
256
455
            let cursor = match &s.selection {
257
455
                Selection::Cursor(c) => *c,
258
                Selection::Range(r) => r.end,
259
            };
260
455
            (mc.node_id.dom, node_id, cursor)
261
455
        }).collect()
262
2730
    }
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
455
    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
455
        let mut map = std::collections::BTreeMap::new();
276
455
        let Some(ref mc) = self.multi_cursor else {
277
            return map;
278
        };
279
455
        let Some(node_id) = mc.node_id.node.into_crate_internal() else {
280
            return map;
281
        };
282

            
283
455
        let mut affected_nodes = std::collections::BTreeMap::new();
284
455
        let mut first_range: Option<azul_core::selection::SelectionRange> = None;
285
910
        for sel in &mc.selections {
286
455
            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
455
            }
292
        }
293

            
294
455
        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
455
        }
312

            
313
455
        map
314
455
    }
315
}