1
//! Text editing changeset system
2
//!
3
//! **STATUS:** The core types (`TextChangeset`, `TextOperation`, `TextOp*` structs) are
4
//! actively used by `window.rs`, `undo_redo.rs`, `event.rs`, and platform code. The
5
//! `create_*_changeset` free functions are not yet wired into event handling.
6
//!
7
//! ## Architecture
8
//!
9
//! This module implements a two-phase changeset system for all text editing operations:
10
//! 1. **Create changesets** (pre-callback): Analyze what would change, don't mutate yet
11
//! 2. **Apply changesets** (post-callback): Actually mutate state if !preventDefault
12
//!
13
//! This pattern enables:
14
//! - preventDefault support for ALL operations (not just text input)
15
//! - Undo/redo stack (record changesets before applying)
16
//! - Validation (check bounds, permissions before mutation)
17
//! - Inspection (user callbacks can see planned changes)
18

            
19
use azul_core::{
20
    dom::DomNodeId,
21
    selection::{OptionSelectionRange, SelectionRange},
22
    task::Instant,
23
    window::CursorPosition,
24
};
25
use azul_css::AzString;
26

            
27
use crate::managers::selection::ClipboardContent;
28

            
29
/// Unique identifier for a changeset (for undo/redo)
30
pub type ChangesetId = usize;
31

            
32
/// A text editing changeset that can be inspected before application
33
#[derive(Debug, Clone)]
34
#[repr(C)]
35
pub struct TextChangeset {
36
    /// Unique ID for undo/redo tracking
37
    pub id: ChangesetId,
38
    /// Target DOM node
39
    pub target: DomNodeId,
40
    /// The operation to perform
41
    pub operation: TextOperation,
42
    /// When this changeset was created
43
    pub timestamp: Instant,
44
}
45

            
46
/// Insert text at cursor position
47
#[derive(Debug, Clone)]
48
#[repr(C)]
49
pub struct TextOpInsertText {
50
    pub text: AzString,
51
    pub position: CursorPosition,
52
    pub new_cursor: CursorPosition,
53
}
54

            
55
/// Delete text in range
56
#[derive(Debug, Clone)]
57
#[repr(C)]
58
pub struct TextOpDeleteText {
59
    pub range: SelectionRange,
60
    pub deleted_text: AzString,
61
    pub new_cursor: CursorPosition,
62
}
63

            
64
/// Replace text in range with new text
65
#[derive(Debug, Clone)]
66
#[repr(C)]
67
pub struct TextOpReplaceText {
68
    pub range: SelectionRange,
69
    pub old_text: AzString,
70
    pub new_text: AzString,
71
    pub new_cursor: CursorPosition,
72
}
73

            
74
/// Set selection to new range
75
#[derive(Debug, Clone)]
76
#[repr(C)]
77
pub struct TextOpSetSelection {
78
    pub old_range: OptionSelectionRange,
79
    pub new_range: SelectionRange,
80
}
81

            
82
/// Extend selection in a direction
83
#[derive(Debug, Clone)]
84
#[repr(C)]
85
pub struct TextOpExtendSelection {
86
    pub old_range: SelectionRange,
87
    pub new_range: SelectionRange,
88
    pub direction: SelectionDirection,
89
}
90

            
91
/// Clear all selections
92
#[derive(Debug, Clone)]
93
#[repr(C)]
94
pub struct TextOpClearSelection {
95
    pub old_range: SelectionRange,
96
}
97

            
98
/// Move cursor to new position
99
#[derive(Debug, Clone)]
100
#[repr(C)]
101
pub struct TextOpMoveCursor {
102
    pub old_position: CursorPosition,
103
    pub new_position: CursorPosition,
104
    pub movement: CursorMovement,
105
}
106

            
107
/// Copy selection to clipboard (no text change)
108
#[derive(Debug, Clone)]
109
#[repr(C)]
110
pub struct TextOpCopy {
111
    pub range: SelectionRange,
112
    pub content: ClipboardContent,
113
}
114

            
115
/// Cut selection to clipboard (deletes text)
116
#[derive(Debug, Clone)]
117
#[repr(C)]
118
pub struct TextOpCut {
119
    pub range: SelectionRange,
120
    pub content: ClipboardContent,
121
    pub new_cursor: CursorPosition,
122
}
123

            
124
/// Paste from clipboard (inserts text)
125
#[derive(Debug, Clone)]
126
#[repr(C)]
127
pub struct TextOpPaste {
128
    pub content: ClipboardContent,
129
    pub position: CursorPosition,
130
    pub new_cursor: CursorPosition,
131
}
132

            
133
/// Select all text in node
134
#[derive(Debug, Clone)]
135
#[repr(C)]
136
pub struct TextOpSelectAll {
137
    pub old_range: OptionSelectionRange,
138
    pub new_range: SelectionRange,
139
}
140

            
141
/// Text editing operation (what will change)
142
#[derive(Debug, Clone)]
143
#[repr(C, u8)]
144
pub enum TextOperation {
145
    /// Insert text at cursor position
146
    InsertText(TextOpInsertText),
147
    /// Delete text in range
148
    DeleteText(TextOpDeleteText),
149
    /// Replace text in range with new text
150
    ReplaceText(TextOpReplaceText),
151
    /// Set selection to new range
152
    SetSelection(TextOpSetSelection),
153
    /// Extend selection in a direction
154
    ExtendSelection(TextOpExtendSelection),
155
    /// Clear all selections
156
    ClearSelection(TextOpClearSelection),
157
    /// Move cursor to new position
158
    MoveCursor(TextOpMoveCursor),
159
    /// Copy selection to clipboard (no text change)
160
    Copy(TextOpCopy),
161
    /// Cut selection to clipboard (deletes text)
162
    Cut(TextOpCut),
163
    /// Paste from clipboard (inserts text)
164
    Paste(TextOpPaste),
165
    /// Select all text in node
166
    SelectAll(TextOpSelectAll),
167
}
168

            
169
/// Re-export from events module
170
pub use azul_core::events::SelectionDirection;
171

            
172
/// Type of cursor movement
173
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174
#[repr(C)]
175
pub enum CursorMovement {
176
    /// Move left one character
177
    Left,
178
    /// Move right one character
179
    Right,
180
    /// Move up one line
181
    Up,
182
    /// Move down one line
183
    Down,
184
    /// Jump to previous word boundary
185
    WordLeft,
186
    /// Jump to next word boundary
187
    WordRight,
188
    /// Jump to start of line
189
    LineStart,
190
    /// Jump to end of line
191
    LineEnd,
192
    /// Jump to start of document
193
    DocumentStart,
194
    /// Jump to end of document
195
    DocumentEnd,
196
    /// Absolute position (not relative)
197
    Absolute,
198
}
199

            
200
impl TextChangeset {
201
    /// Create a new changeset with unique ID
202
    pub fn new(target: DomNodeId, operation: TextOperation, timestamp: Instant) -> Self {
203
        use std::sync::atomic::{AtomicUsize, Ordering};
204
        static CHANGESET_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
205

            
206
        Self {
207
            id: CHANGESET_ID_COUNTER.fetch_add(1, Ordering::Relaxed),
208
            target,
209
            operation,
210
            timestamp,
211
        }
212
    }
213

            
214
    /// Check if this changeset actually mutates text (vs just selection/cursor)
215
    pub fn mutates_text(&self) -> bool {
216
        matches!(
217
            self.operation,
218
            TextOperation::InsertText { .. }
219
                | TextOperation::DeleteText { .. }
220
                | TextOperation::ReplaceText { .. }
221
                | TextOperation::Cut { .. }
222
                | TextOperation::Paste { .. }
223
        )
224
    }
225

            
226
    /// Check if this changeset changes selection (including cursor moves)
227
    pub fn changes_selection(&self) -> bool {
228
        matches!(
229
            self.operation,
230
            TextOperation::SetSelection { .. }
231
                | TextOperation::ExtendSelection { .. }
232
                | TextOperation::ClearSelection { .. }
233
                | TextOperation::MoveCursor { .. }
234
                | TextOperation::SelectAll { .. }
235
        )
236
    }
237

            
238
    /// Check if this changeset involves clipboard
239
    pub fn uses_clipboard(&self) -> bool {
240
        matches!(
241
            self.operation,
242
            TextOperation::Copy { .. } | TextOperation::Cut { .. } | TextOperation::Paste { .. }
243
        )
244
    }
245

            
246
    /// Get the target cursor position after this changeset is applied
247
    pub fn resulting_cursor_position(&self) -> Option<CursorPosition> {
248
        match &self.operation {
249
            TextOperation::InsertText(op) => Some(op.new_cursor),
250
            TextOperation::DeleteText(op) => Some(op.new_cursor),
251
            TextOperation::ReplaceText(op) => Some(op.new_cursor),
252
            TextOperation::Cut(op) => Some(op.new_cursor),
253
            TextOperation::Paste(op) => Some(op.new_cursor),
254
            TextOperation::MoveCursor(op) => Some(op.new_position),
255
            _ => None,
256
        }
257
    }
258

            
259
    /// Get the target selection range after this changeset is applied
260
    pub fn resulting_selection_range(&self) -> Option<SelectionRange> {
261
        match &self.operation {
262
            TextOperation::SetSelection(op) => Some(op.new_range),
263
            TextOperation::ExtendSelection(op) => Some(op.new_range),
264
            TextOperation::SelectAll(op) => Some(op.new_range),
265
            _ => None,
266
        }
267
    }
268
}
269

            
270
/// Creates a copy changeset from the current selection.
271
///
272
/// Extracts the selected text content and creates a `TextChangeset` with a `Copy`
273
/// operation. Returns `None` if there is no selection or no content to copy.
274
pub fn create_copy_changeset(
275
    target: DomNodeId,
276
    timestamp: Instant,
277
    layout_window: &crate::window::LayoutWindow,
278
) -> Option<TextChangeset> {
279
    // Extract clipboard content from current selection
280
    let dom_id = &target.dom;
281
    let content = layout_window.get_selected_content_for_clipboard(dom_id)?;
282

            
283
    // Get selection range for changeset
284
    let ranges: Vec<azul_core::selection::SelectionRange> = layout_window.text_edit_manager.multi_cursor.as_ref()
285
        .map(|mc| mc.selections.iter().filter_map(|s| match &s.selection {
286
            azul_core::selection::Selection::Range(r) => Some(*r),
287
            _ => None,
288
        }).collect()).unwrap_or_default();
289
    let range = ranges.first()?;
290

            
291
    Some(TextChangeset::new(
292
        target,
293
        TextOperation::Copy(TextOpCopy {
294
            range: *range,
295
            content,
296
        }),
297
        timestamp,
298
    ))
299
}
300

            
301
/// Creates a cut changeset from the current selection.
302
///
303
/// Extracts the selected text content and creates a `TextChangeset` with a `Cut`
304
/// operation that will delete the selected text after copying it to clipboard.
305
/// Returns `None` if there is no selection or no content to cut.
306
pub fn create_cut_changeset(
307
    target: DomNodeId,
308
    timestamp: Instant,
309
    layout_window: &crate::window::LayoutWindow,
310
) -> Option<TextChangeset> {
311
    // Extract clipboard content from current selection
312
    let dom_id = &target.dom;
313
    let content = layout_window.get_selected_content_for_clipboard(dom_id)?;
314

            
315
    // Get selection range for changeset
316
    let ranges: Vec<azul_core::selection::SelectionRange> = layout_window.text_edit_manager.multi_cursor.as_ref()
317
        .map(|mc| mc.selections.iter().filter_map(|s| match &s.selection {
318
            azul_core::selection::Selection::Range(r) => Some(*r),
319
            _ => None,
320
        }).collect()).unwrap_or_default();
321
    let range = ranges.first()?;
322

            
323
    // The logical cursor will be at the start of the deleted range
324
    // SelectionManager will map this to physical coordinates
325
    let new_cursor_position = azul_core::window::CursorPosition::Uninitialized;
326

            
327
    Some(TextChangeset::new(
328
        target,
329
        TextOperation::Cut(TextOpCut {
330
            range: *range,
331
            content,
332
            new_cursor: new_cursor_position,
333
        }),
334
        timestamp,
335
    ))
336
}
337

            
338
/// Creates a paste changeset at the current cursor position.
339
///
340
/// Note: The actual clipboard content must be provided by the caller (typically
341
/// `event_v2.rs`), as clipboard access is platform-specific and not available
342
/// in the layout engine. This function currently returns `None` and paste
343
/// operations are initiated from `event_v2.rs` with pre-read clipboard content.
344
pub fn create_paste_changeset(
345
    target: DomNodeId,
346
    timestamp: Instant,
347
    layout_window: &crate::window::LayoutWindow,
348
) -> Option<TextChangeset> {
349
    // Paste is handled by event_v2.rs with clipboard content parameter.
350
    // This stub exists for API consistency with other changeset creators.
351
    None
352
}
353

            
354
/// Creates a select-all changeset for the target node.
355
///
356
/// Selects all text content in the target node from the beginning to the end.
357
/// Returns `None` if the node has no text content.
358
pub fn create_select_all_changeset(
359
    target: DomNodeId,
360
    timestamp: Instant,
361
    layout_window: &crate::window::LayoutWindow,
362
) -> Option<TextChangeset> {
363
    use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
364

            
365
    let dom_id = &target.dom;
366
    let node_id = target.node.into_crate_internal()?;
367

            
368
    // Get current selection (if any) for undo
369
    let old_range: Option<azul_core::selection::SelectionRange> = layout_window.text_edit_manager.multi_cursor.as_ref()
370
        .and_then(|mc| mc.selections.iter().find_map(|s| match &s.selection {
371
            azul_core::selection::Selection::Range(r) => Some(*r),
372
            _ => None,
373
        }));
374

            
375
    // Get the text content to determine end position
376
    let content = layout_window.get_text_before_textinput(*dom_id, node_id);
377
    let text = layout_window.extract_text_from_inline_content(&content);
378

            
379
    // Create selection range from start to end of text
380
    let start_cursor = TextCursor {
381
        cluster_id: GraphemeClusterId {
382
            source_run: 0,
383
            start_byte_in_run: 0,
384
        },
385
        affinity: CursorAffinity::Leading,
386
    };
387

            
388
    let end_cursor = TextCursor {
389
        cluster_id: GraphemeClusterId {
390
            source_run: 0,
391
            start_byte_in_run: text.len() as u32,
392
        },
393
        affinity: CursorAffinity::Leading,
394
    };
395

            
396
    let new_range = azul_core::selection::SelectionRange {
397
        start: start_cursor,
398
        end: end_cursor,
399
    };
400

            
401
    Some(TextChangeset::new(
402
        target,
403
        TextOperation::SelectAll(TextOpSelectAll {
404
            old_range: old_range.into(),
405
            new_range,
406
        }),
407
        timestamp,
408
    ))
409
}
410

            
411
/// Creates a delete changeset for the current selection or single character.
412
///
413
/// If there is an active selection, deletes the entire selection.
414
/// If there is only a cursor (no selection), deletes a single character:
415
/// - `forward = true` (Delete key): deletes the character after the cursor
416
/// - `forward = false` (Backspace): deletes the character before the cursor
417
///
418
/// Returns `None` if there is nothing to delete (e.g., cursor at document boundary).
419
pub fn create_delete_selection_changeset(
420
    target: DomNodeId,
421
    forward: bool,
422
    timestamp: Instant,
423
    layout_window: &crate::window::LayoutWindow,
424
) -> Option<TextChangeset> {
425
    use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
426

            
427
    let dom_id = &target.dom;
428
    let node_id = target.node.into_crate_internal()?;
429

            
430
    // Get current selection/cursor
431
    let ranges: Vec<azul_core::selection::SelectionRange> = layout_window.text_edit_manager.multi_cursor.as_ref()
432
        .map(|mc| mc.selections.iter().filter_map(|s| match &s.selection {
433
            azul_core::selection::Selection::Range(r) => Some(*r),
434
            _ => None,
435
        }).collect()).unwrap_or_default();
436
    let cursor = layout_window.text_edit_manager.get_primary_cursor();
437

            
438
    // Determine what to delete
439
    let (delete_range, deleted_text) = if let Some(range) = ranges.first() {
440
        // Selection exists - delete the selection
441
        let content = layout_window.get_text_before_textinput(*dom_id, node_id);
442
        let text = layout_window.extract_text_from_inline_content(&content);
443

            
444
        // Extract the text in the range
445
        // For now, simplified: delete entire selection
446
        // TODO: Actually extract text between range.start and range.end
447
        let deleted = String::new(); // Placeholder
448

            
449
        (*range, deleted)
450
    } else if let Some(cursor_pos) = cursor {
451
        // No selection - delete one character
452
        let content = layout_window.get_text_before_textinput(*dom_id, node_id);
453
        let text = layout_window.extract_text_from_inline_content(&content);
454

            
455
        let byte_pos = cursor_pos.cluster_id.start_byte_in_run as usize;
456

            
457
        let (range, deleted) = if forward {
458
            // Delete key - delete character after cursor
459
            if byte_pos >= text.len() {
460
                return None; // At end, nothing to delete
461
            }
462
            // Delete one character forward
463
            let end_pos = (byte_pos + 1).min(text.len());
464
            let deleted = text[byte_pos..end_pos].to_string();
465

            
466
            let range = azul_core::selection::SelectionRange {
467
                start: cursor_pos,
468
                end: TextCursor {
469
                    cluster_id: GraphemeClusterId {
470
                        source_run: cursor_pos.cluster_id.source_run,
471
                        start_byte_in_run: end_pos as u32,
472
                    },
473
                    affinity: CursorAffinity::Leading,
474
                },
475
            };
476
            (range, deleted)
477
        } else {
478
            // Backspace - delete character before cursor
479
            if byte_pos == 0 {
480
                return None; // At start, nothing to delete
481
            }
482
            // Delete one character backward
483
            let start_pos = byte_pos.saturating_sub(1);
484
            let deleted = text[start_pos..byte_pos].to_string();
485

            
486
            let range = azul_core::selection::SelectionRange {
487
                start: TextCursor {
488
                    cluster_id: GraphemeClusterId {
489
                        source_run: cursor_pos.cluster_id.source_run,
490
                        start_byte_in_run: start_pos as u32,
491
                    },
492
                    affinity: CursorAffinity::Leading,
493
                },
494
                end: cursor_pos,
495
            };
496
            (range, deleted)
497
        };
498

            
499
        (range, deleted)
500
    } else {
501
        return None; // No cursor or selection
502
    };
503

            
504
    // New cursor position after deletion (at start of deleted range)
505
    let new_cursor = azul_core::window::CursorPosition::Uninitialized;
506

            
507
    Some(TextChangeset::new(
508
        target,
509
        TextOperation::DeleteText(TextOpDeleteText {
510
            range: delete_range,
511
            deleted_text: deleted_text.into(),
512
            new_cursor,
513
        }),
514
        timestamp,
515
    ))
516
}