Skip to main content

azul_layout/managers/
changeset.rs

1//! Text editing changeset system (FUTURE ARCHITECTURE - NOT YET IMPLEMENTED)
2//!
3//! **STATUS:** This module defines the planned architecture for a unified text editing
4//! changeset system, but is not yet implemented. Current text editing works through:
5//! - `text3::edit` module for text manipulation
6//! - `managers::text_input` for event recording
7//! - `window.rs` for integration
8//!
9//! This module serves as a design document for post-1.0 refactoring.
10//!
11//! ## Planned Architecture (Future)
12//!
13//! This module will implement a two-phase changeset system for all text editing operations:
14//! 1. **Create changesets** (pre-callback): Analyze what would change, don't mutate yet
15//! 2. **Apply changesets** (post-callback): Actually mutate state if !preventDefault
16//!
17//! This pattern will enable:
18//! - preventDefault support for ALL operations (not just text input)
19//! - Undo/redo stack (record changesets before applying)
20//! - Validation (check bounds, permissions before mutation)
21//! - Inspection (user callbacks can see planned changes)
22
23use azul_core::{
24    dom::DomNodeId,
25    geom::LogicalPosition,
26    selection::{OptionSelectionRange, SelectionRange},
27    task::Instant,
28    window::CursorPosition,
29};
30use azul_css::AzString;
31
32use crate::managers::selection::ClipboardContent;
33
34/// Unique identifier for a changeset (for undo/redo)
35pub type ChangesetId = usize;
36
37/// A text editing changeset that can be inspected before application
38#[derive(Debug, Clone)]
39#[repr(C)]
40pub struct TextChangeset {
41    /// Unique ID for undo/redo tracking
42    pub id: ChangesetId,
43    /// Target DOM node
44    pub target: DomNodeId,
45    /// The operation to perform
46    pub operation: TextOperation,
47    /// When this changeset was created
48    pub timestamp: Instant,
49}
50
51/// Insert text at cursor position
52#[derive(Debug, Clone)]
53#[repr(C)]
54pub struct TextOpInsertText {
55    pub position: CursorPosition,
56    pub text: AzString,
57    pub new_cursor: CursorPosition,
58}
59
60/// Delete text in range
61#[derive(Debug, Clone)]
62#[repr(C)]
63pub struct TextOpDeleteText {
64    pub range: SelectionRange,
65    pub deleted_text: AzString,
66    pub new_cursor: CursorPosition,
67}
68
69/// Replace text in range with new text
70#[derive(Debug, Clone)]
71#[repr(C)]
72pub struct TextOpReplaceText {
73    pub range: SelectionRange,
74    pub old_text: AzString,
75    pub new_text: AzString,
76    pub new_cursor: CursorPosition,
77}
78
79/// Set selection to new range
80#[derive(Debug, Clone)]
81#[repr(C)]
82pub struct TextOpSetSelection {
83    pub old_range: OptionSelectionRange,
84    pub new_range: SelectionRange,
85}
86
87/// Extend selection in a direction
88#[derive(Debug, Clone)]
89#[repr(C)]
90pub struct TextOpExtendSelection {
91    pub old_range: SelectionRange,
92    pub new_range: SelectionRange,
93    pub direction: SelectionDirection,
94}
95
96/// Clear all selections
97#[derive(Debug, Clone)]
98#[repr(C)]
99pub struct TextOpClearSelection {
100    pub old_range: SelectionRange,
101}
102
103/// Move cursor to new position
104#[derive(Debug, Clone)]
105#[repr(C)]
106pub struct TextOpMoveCursor {
107    pub old_position: CursorPosition,
108    pub new_position: CursorPosition,
109    pub movement: CursorMovement,
110}
111
112/// Copy selection to clipboard (no text change)
113#[derive(Debug, Clone)]
114#[repr(C)]
115pub struct TextOpCopy {
116    pub range: SelectionRange,
117    pub content: ClipboardContent,
118}
119
120/// Cut selection to clipboard (deletes text)
121#[derive(Debug, Clone)]
122#[repr(C)]
123pub struct TextOpCut {
124    pub range: SelectionRange,
125    pub content: ClipboardContent,
126    pub new_cursor: CursorPosition,
127}
128
129/// Paste from clipboard (inserts text)
130#[derive(Debug, Clone)]
131#[repr(C)]
132pub struct TextOpPaste {
133    pub position: CursorPosition,
134    pub content: ClipboardContent,
135    pub new_cursor: CursorPosition,
136}
137
138/// Select all text in node
139#[derive(Debug, Clone)]
140#[repr(C)]
141pub struct TextOpSelectAll {
142    pub old_range: OptionSelectionRange,
143    pub new_range: SelectionRange,
144}
145
146/// Text editing operation (what will change)
147#[derive(Debug, Clone)]
148#[repr(C, u8)]
149pub enum TextOperation {
150    /// Insert text at cursor position
151    InsertText(TextOpInsertText),
152    /// Delete text in range
153    DeleteText(TextOpDeleteText),
154    /// Replace text in range with new text
155    ReplaceText(TextOpReplaceText),
156    /// Set selection to new range
157    SetSelection(TextOpSetSelection),
158    /// Extend selection in a direction
159    ExtendSelection(TextOpExtendSelection),
160    /// Clear all selections
161    ClearSelection(TextOpClearSelection),
162    /// Move cursor to new position
163    MoveCursor(TextOpMoveCursor),
164    /// Copy selection to clipboard (no text change)
165    Copy(TextOpCopy),
166    /// Cut selection to clipboard (deletes text)
167    Cut(TextOpCut),
168    /// Paste from clipboard (inserts text)
169    Paste(TextOpPaste),
170    /// Select all text in node
171    SelectAll(TextOpSelectAll),
172}
173
174/// Direction of selection extension
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176#[repr(C)]
177pub enum SelectionDirection {
178    /// Extending selection forward (to the right/down)
179    Forward,
180    /// Extending selection backward (to the left/up)
181    Backward,
182}
183
184/// Type of cursor movement
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186#[repr(C)]
187pub enum CursorMovement {
188    /// Move left one character
189    Left,
190    /// Move right one character
191    Right,
192    /// Move up one line
193    Up,
194    /// Move down one line
195    Down,
196    /// Jump to previous word boundary
197    WordLeft,
198    /// Jump to next word boundary
199    WordRight,
200    /// Jump to start of line
201    LineStart,
202    /// Jump to end of line
203    LineEnd,
204    /// Jump to start of document
205    DocumentStart,
206    /// Jump to end of document
207    DocumentEnd,
208    /// Absolute position (not relative)
209    Absolute,
210}
211
212impl TextChangeset {
213    /// Create a new changeset with unique ID
214    pub fn new(target: DomNodeId, operation: TextOperation, timestamp: Instant) -> Self {
215        use std::sync::atomic::{AtomicUsize, Ordering};
216        static CHANGESET_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
217
218        Self {
219            id: CHANGESET_ID_COUNTER.fetch_add(1, Ordering::Relaxed),
220            target,
221            operation,
222            timestamp,
223        }
224    }
225
226    /// Check if this changeset actually mutates text (vs just selection/cursor)
227    pub fn mutates_text(&self) -> bool {
228        matches!(
229            self.operation,
230            TextOperation::InsertText { .. }
231                | TextOperation::DeleteText { .. }
232                | TextOperation::ReplaceText { .. }
233                | TextOperation::Cut { .. }
234                | TextOperation::Paste { .. }
235        )
236    }
237
238    /// Check if this changeset changes selection (including cursor moves)
239    pub fn changes_selection(&self) -> bool {
240        matches!(
241            self.operation,
242            TextOperation::SetSelection { .. }
243                | TextOperation::ExtendSelection { .. }
244                | TextOperation::ClearSelection { .. }
245                | TextOperation::MoveCursor { .. }
246                | TextOperation::SelectAll { .. }
247        )
248    }
249
250    /// Check if this changeset involves clipboard
251    pub fn uses_clipboard(&self) -> bool {
252        matches!(
253            self.operation,
254            TextOperation::Copy { .. } | TextOperation::Cut { .. } | TextOperation::Paste { .. }
255        )
256    }
257
258    /// Get the target cursor position after this changeset is applied
259    pub fn resulting_cursor_position(&self) -> Option<CursorPosition> {
260        match &self.operation {
261            TextOperation::InsertText(op) => Some(op.new_cursor),
262            TextOperation::DeleteText(op) => Some(op.new_cursor),
263            TextOperation::ReplaceText(op) => Some(op.new_cursor),
264            TextOperation::Cut(op) => Some(op.new_cursor),
265            TextOperation::Paste(op) => Some(op.new_cursor),
266            TextOperation::MoveCursor(op) => Some(op.new_position),
267            _ => None,
268        }
269    }
270
271    /// Get the target selection range after this changeset is applied
272    pub fn resulting_selection_range(&self) -> Option<SelectionRange> {
273        match &self.operation {
274            TextOperation::SetSelection(op) => Some(op.new_range),
275            TextOperation::ExtendSelection(op) => Some(op.new_range),
276            TextOperation::SelectAll(op) => Some(op.new_range),
277            _ => None,
278        }
279    }
280}
281
282/// Returns the current system time using external callbacks.
283fn get_current_time() -> Instant {
284    let external = crate::callbacks::ExternalSystemCallbacks::rust_internal();
285    (external.get_system_time_fn.cb)().into()
286}
287
288/// Creates a copy changeset from the current selection.
289///
290/// Extracts the selected text content and creates a `TextChangeset` with a `Copy`
291/// operation. Returns `None` if there is no selection or no content to copy.
292pub fn create_copy_changeset(
293    target: DomNodeId,
294    timestamp: Instant,
295    layout_window: &crate::window::LayoutWindow,
296) -> Option<TextChangeset> {
297    // Extract clipboard content from current selection
298    let dom_id = &target.dom;
299    let content = layout_window.get_selected_content_for_clipboard(dom_id)?;
300
301    // Get selection range for changeset
302    let ranges = layout_window.selection_manager.get_ranges(dom_id);
303    let range = ranges.first()?;
304
305    Some(TextChangeset::new(
306        target,
307        TextOperation::Copy(TextOpCopy {
308            range: *range,
309            content,
310        }),
311        timestamp,
312    ))
313}
314
315/// Creates a cut changeset from the current selection.
316///
317/// Extracts the selected text content and creates a `TextChangeset` with a `Cut`
318/// operation that will delete the selected text after copying it to clipboard.
319/// Returns `None` if there is no selection or no content to cut.
320pub fn create_cut_changeset(
321    target: DomNodeId,
322    timestamp: Instant,
323    layout_window: &crate::window::LayoutWindow,
324) -> Option<TextChangeset> {
325    // Extract clipboard content from current selection
326    let dom_id = &target.dom;
327    let content = layout_window.get_selected_content_for_clipboard(dom_id)?;
328
329    // Get selection range for changeset
330    let ranges = layout_window.selection_manager.get_ranges(dom_id);
331    let range = ranges.first()?;
332
333    // The logical cursor will be at the start of the deleted range
334    // SelectionManager will map this to physical coordinates
335    let new_cursor_position = azul_core::window::CursorPosition::Uninitialized;
336
337    Some(TextChangeset::new(
338        target,
339        TextOperation::Cut(TextOpCut {
340            range: *range,
341            content,
342            new_cursor: new_cursor_position,
343        }),
344        timestamp,
345    ))
346}
347
348/// Creates a paste changeset at the current cursor position.
349///
350/// Note: The actual clipboard content must be provided by the caller (typically
351/// `event_v2.rs`), as clipboard access is platform-specific and not available
352/// in the layout engine. This function currently returns `None` and paste
353/// operations are initiated from `event_v2.rs` with pre-read clipboard content.
354pub fn create_paste_changeset(
355    target: DomNodeId,
356    timestamp: Instant,
357    layout_window: &crate::window::LayoutWindow,
358) -> Option<TextChangeset> {
359    // Paste is handled by event_v2.rs with clipboard content parameter.
360    // This stub exists for API consistency with other changeset creators.
361    None
362}
363
364/// Creates a select-all changeset for the target node.set for the target node.
365///
366/// Selects all text content in the target node from the beginning to the end.
367/// Returns `None` if the node has no text content.
368pub fn create_select_all_changeset(
369    target: DomNodeId,
370    timestamp: Instant,
371    layout_window: &crate::window::LayoutWindow,
372) -> Option<TextChangeset> {
373    use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
374
375    let dom_id = &target.dom;
376    let node_id = target.node.into_crate_internal()?;
377
378    // Get current selection (if any) for undo
379    let old_range = layout_window
380        .selection_manager
381        .get_ranges(dom_id)
382        .first()
383        .copied();
384
385    // Get the text content to determine end position
386    let content = layout_window.get_text_before_textinput(*dom_id, node_id);
387    let text = layout_window.extract_text_from_inline_content(&content);
388
389    // Create selection range from start to end of text
390    let start_cursor = TextCursor {
391        cluster_id: GraphemeClusterId {
392            source_run: 0,
393            start_byte_in_run: 0,
394        },
395        affinity: CursorAffinity::Leading,
396    };
397
398    let end_cursor = TextCursor {
399        cluster_id: GraphemeClusterId {
400            source_run: 0,
401            start_byte_in_run: text.len() as u32,
402        },
403        affinity: CursorAffinity::Leading,
404    };
405
406    let new_range = azul_core::selection::SelectionRange {
407        start: start_cursor,
408        end: end_cursor,
409    };
410
411    Some(TextChangeset::new(
412        target,
413        TextOperation::SelectAll(TextOpSelectAll {
414            old_range: old_range.into(),
415            new_range,
416        }),
417        timestamp,
418    ))
419}
420
421/// Creates a delete changeset for the current selection or single character.
422///
423/// If there is an active selection, deletes the entire selection.
424/// If there is only a cursor (no selection), deletes a single character:
425/// - `forward = true` (Delete key): deletes the character after the cursor
426/// - `forward = false` (Backspace): deletes the character before the cursor
427///
428/// Returns `None` if there is nothing to delete (e.g., cursor at document boundary).
429pub fn create_delete_selection_changeset(
430    target: DomNodeId,
431    forward: bool,
432    timestamp: Instant,
433    layout_window: &crate::window::LayoutWindow,
434) -> Option<TextChangeset> {
435    use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
436
437    let dom_id = &target.dom;
438    let node_id = target.node.into_crate_internal()?;
439
440    // Get current selection/cursor
441    let ranges = layout_window.selection_manager.get_ranges(dom_id);
442    let cursor = layout_window.cursor_manager.get_cursor();
443
444    // Determine what to delete
445    let (delete_range, deleted_text) = if let Some(range) = ranges.first() {
446        // Selection exists - delete the selection
447        let content = layout_window.get_text_before_textinput(*dom_id, node_id);
448        let text = layout_window.extract_text_from_inline_content(&content);
449
450        // Extract the text in the range
451        // For now, simplified: delete entire selection
452        // TODO: Actually extract text between range.start and range.end
453        let deleted = String::new(); // Placeholder
454
455        (*range, deleted)
456    } else if let Some(cursor_pos) = cursor {
457        // No selection - delete one character
458        let content = layout_window.get_text_before_textinput(*dom_id, node_id);
459        let text = layout_window.extract_text_from_inline_content(&content);
460
461        let byte_pos = cursor_pos.cluster_id.start_byte_in_run as usize;
462
463        let (range, deleted) = if forward {
464            // Delete key - delete character after cursor
465            if byte_pos >= text.len() {
466                return None; // At end, nothing to delete
467            }
468            // Delete one character forward
469            let end_pos = (byte_pos + 1).min(text.len());
470            let deleted = text[byte_pos..end_pos].to_string();
471
472            let range = azul_core::selection::SelectionRange {
473                start: *cursor_pos,
474                end: TextCursor {
475                    cluster_id: GraphemeClusterId {
476                        source_run: cursor_pos.cluster_id.source_run,
477                        start_byte_in_run: end_pos as u32,
478                    },
479                    affinity: CursorAffinity::Leading,
480                },
481            };
482            (range, deleted)
483        } else {
484            // Backspace - delete character before cursor
485            if byte_pos == 0 {
486                return None; // At start, nothing to delete
487            }
488            // Delete one character backward
489            let start_pos = byte_pos.saturating_sub(1);
490            let deleted = text[start_pos..byte_pos].to_string();
491
492            let range = azul_core::selection::SelectionRange {
493                start: TextCursor {
494                    cluster_id: GraphemeClusterId {
495                        source_run: cursor_pos.cluster_id.source_run,
496                        start_byte_in_run: start_pos as u32,
497                    },
498                    affinity: CursorAffinity::Leading,
499                },
500                end: *cursor_pos,
501            };
502            (range, deleted)
503        };
504
505        (range, deleted)
506    } else {
507        return None; // No cursor or selection
508    };
509
510    // New cursor position after deletion (at start of deleted range)
511    let new_cursor = azul_core::window::CursorPosition::Uninitialized;
512
513    Some(TextChangeset::new(
514        target,
515        TextOperation::DeleteText(TextOpDeleteText {
516            range: delete_range,
517            deleted_text: deleted_text.into(),
518            new_cursor,
519        }),
520        timestamp,
521    ))
522}