Skip to main content

azul_layout/managers/
text_input.rs

1//! Text Input Manager
2//!
3//! Centralizes all text editing logic for contenteditable nodes.
4//!
5//! This manager handles text input from multiple sources:
6//!
7//! - Keyboard input (character insertion, backspace, etc.)
8//! - IME composition (multi-character input for Asian languages)
9//! - Accessibility actions (screen readers, voice control)
10//! - Programmatic edits (from callbacks)
11//!
12//! ## Architecture
13//!
14//! The text input system uses a two-phase approach:
15//!
16//! 1. **Record Phase**: When text input occurs, record what changed (`old_text` + `inserted_text`)
17//!
18//!    - Store in `pending_changeset`
19//!    - Do NOT modify any caches yet
20//!    - Return affected nodes so callbacks can be invoked
21//!
22//! 2. **Apply Phase**: After callbacks, if preventDefault was not set:
23//!
24//!    - Compute new text using `text3::edit`
25//!    - Update cursor position
26//!    - Update text cache
27//!    - Mark nodes dirty for re-layout
28//!
29//! This separation allows:
30//!
31//! - User callbacks to inspect the changeset before it's applied
32//! - preventDefault to cancel the edit
33//! - Consistent behavior across keyboard/IME/A11y sources
34
35use azul_core::{
36    dom::DomNodeId,
37    events::{
38        EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
39        TextInputEventData,
40    },
41    task::Instant,
42};
43use azul_css::corety::AzString;
44
45/// Information about a pending text edit that hasn't been applied yet
46#[derive(Debug, Clone)]
47#[repr(C)]
48pub struct PendingTextEdit {
49    /// The node that was edited
50    pub node: DomNodeId,
51    /// The text that was inserted
52    pub inserted_text: AzString,
53    /// The old text before the edit (plain text extracted from `InlineContent`)
54    pub old_text: AzString,
55}
56
57impl PendingTextEdit {
58    /// Preview the resulting text by appending `inserted_text` to `old_text`.
59    ///
60    /// NOTE: Actual cursor-based insertion is handled by `apply_text_changeset()`
61    /// in window.rs via `text3::edit::insert_text()`.
62    #[must_use] pub fn resulting_text(&self) -> AzString {
63        let mut result = self.old_text.as_str().to_string();
64        result.push_str(self.inserted_text.as_str());
65        result.into()
66    }
67}
68#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
69/// C-compatible Option type for `PendingTextEdit`
70#[derive(Debug, Clone)]
71#[repr(C, u8)]
72pub enum OptionPendingTextEdit {
73    None,
74    Some(PendingTextEdit),
75}
76
77impl OptionPendingTextEdit {
78    #[must_use] pub fn into_option(self) -> Option<PendingTextEdit> {
79        match self {
80            Self::None => None,
81            Self::Some(t) => Some(t),
82        }
83    }
84}
85
86impl From<Option<PendingTextEdit>> for OptionPendingTextEdit {
87    fn from(o: Option<PendingTextEdit>) -> Self {
88        o.map_or_else(|| Self::None, Self::Some)
89    }
90}
91
92impl<'a> From<Option<&'a PendingTextEdit>> for OptionPendingTextEdit {
93    fn from(o: Option<&'a PendingTextEdit>) -> Self {
94        o.map_or_else(|| Self::None, |v| Self::Some(v.clone()))
95    }
96}
97
98/// Source of a text input event
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TextInputSource {
101    /// Regular keyboard input
102    Keyboard,
103    /// IME composition (multi-character input)
104    Ime,
105    /// Accessibility action from assistive technology
106    Accessibility,
107    /// Programmatic edit from user callback
108    Programmatic,
109}
110
111/// Text Input Manager
112///
113/// Centralizes all text editing logic. This is the single source of truth
114/// for text input state.
115#[derive(Debug)]
116pub struct TextInputManager {
117    /// The pending text changeset that hasn't been applied yet.
118    /// This is set during the "record" phase and cleared after the "apply" phase.
119    pub pending_changeset: Option<PendingTextEdit>,
120    /// Source of the current text input
121    pub input_source: Option<TextInputSource>,
122}
123
124impl TextInputManager {
125    /// Create a new `TextInputManager`
126    #[must_use] pub const fn new() -> Self {
127        Self {
128            pending_changeset: None,
129            input_source: None,
130        }
131    }
132
133    /// Record a text input event (Phase 1)
134    ///
135    /// This ONLY records what text was inserted. It does NOT apply the changes yet.
136    /// The changes are applied later in `apply_changeset()` if preventDefault is not set.
137    ///
138    /// # Arguments
139    ///
140    /// - `node` - The DOM node being edited
141    /// - `inserted_text` - The text being inserted
142    /// - `old_text` - The current text before the edit
143    /// - `source` - Where the input came from (keyboard, IME, A11y, etc.)
144    ///
145    /// Returns the affected node for event generation.
146    pub fn record_input(
147        &mut self,
148        node: DomNodeId,
149        inserted_text: String,
150        old_text: String,
151        source: TextInputSource,
152    ) -> DomNodeId {
153        self.pending_changeset = Some(PendingTextEdit {
154            node,
155            inserted_text: inserted_text.into(),
156            old_text: old_text.into(),
157        });
158
159        self.input_source = Some(source);
160
161        node
162    }
163
164    /// Get the pending changeset (if any)
165    #[must_use] pub const fn get_pending_changeset(&self) -> Option<&PendingTextEdit> {
166        self.pending_changeset.as_ref()
167    }
168
169    /// Clear the pending changeset
170    ///
171    /// This is called after applying the changeset or if preventDefault was set.
172    pub fn clear_changeset(&mut self) {
173        self.pending_changeset = None;
174        self.input_source = None;
175    }
176}
177
178impl Default for TextInputManager {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl EventProvider for TextInputManager {
185    /// Get pending text input events.
186    ///
187    /// If there's a pending changeset, returns an Input event for the affected node.
188    /// The event data includes the old text and inserted text so callbacks can
189    /// query the changeset.
190    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
191    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
192        let mut events = Vec::new();
193
194        if let Some(changeset) = &self.pending_changeset {
195            let event_source = match self.input_source {
196                Some(TextInputSource::Keyboard | TextInputSource::Ime) => {
197                    CoreEventSource::User
198                }
199                Some(TextInputSource::Accessibility) => CoreEventSource::User, /* A11y is still */
200                // user input
201                Some(TextInputSource::Programmatic) => CoreEventSource::Programmatic,
202                None => CoreEventSource::User,
203            };
204
205            // Generate Input event (fires on every keystroke).
206            // Carry the edit details on the event itself (inserted/old text) so
207            // callbacks read them straight off the event — like other event
208            // types — without having to query `get_pending_changeset()`. The
209            // edited node is available via `SyntheticEvent.target`.
210            events.push(SyntheticEvent::new(
211                EventType::Input,
212                event_source,
213                changeset.node,
214                timestamp,
215                EventData::TextInput(TextInputEventData {
216                    inserted_text: changeset.inserted_text.as_str().to_string(),
217                    old_text: changeset.old_text.as_str().to_string(),
218                }),
219            ));
220
221            // Note: We don't generate Change events here - those are generated
222            // when focus is lost or Enter is pressed (handled elsewhere)
223        }
224
225        events
226    }
227}
228
229impl crate::managers::NodeIdRemap for TextInputManager {
230    /// Remap the pending (recorded, not-yet-applied) text edit.
231    ///
232    /// If the target node was unmounted between "record" and "apply", the
233    /// changeset is dropped: applying it would insert the text into whichever
234    /// node inherited the index.
235    fn remap_node_ids(&mut self, dom: azul_core::dom::DomId, map: &crate::managers::NodeIdMap) {
236        let Some(ref mut pending) = self.pending_changeset else {
237            return;
238        };
239        match map.resolve_dom_node_id(dom, pending.node) {
240            Some(new_id) => pending.node = new_id,
241            None => {
242                self.pending_changeset = None;
243                self.input_source = None;
244            }
245        }
246    }
247}