Skip to main content

azul_layout/managers/
changeset.rs

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
19use azul_core::{
20    dom::DomNodeId,
21    selection::{OptionSelectionRange, SelectionRange},
22    task::Instant,
23    window::CursorPosition,
24};
25use azul_css::AzString;
26
27use crate::managers::selection::ClipboardContent;
28
29/// Unique identifier for a changeset (for undo/redo)
30pub type ChangesetId = usize;
31
32/// A text editing changeset that can be inspected before application
33#[derive(Debug, Clone)]
34#[repr(C)]
35pub 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)]
49pub 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)]
58pub 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)]
67pub 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)]
77pub 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)]
85pub 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)]
94pub struct TextOpClearSelection {
95    pub old_range: SelectionRange,
96}
97
98/// Move cursor to new position
99#[derive(Debug, Clone)]
100#[repr(C)]
101pub 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)]
110pub 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)]
118pub 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)]
127pub 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)]
136pub 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)]
144pub 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
170pub use azul_core::events::SelectionDirection;
171
172/// Type of cursor movement
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174#[repr(C)]
175pub 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
200impl 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.
274pub 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.
306pub 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.
344pub 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.
358pub 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).
419pub 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}