Skip to main content

cranpose_foundation/text/
state.rs

1use std::{
2    cell::{Cell, RefCell},
3    collections::VecDeque,
4    hash::{Hash, Hasher},
5    rc::Rc,
6};
7
8use cranpose_core::MutableState;
9
10use super::{TextFieldBuffer, TextRange};
11
12/// Immutable snapshot of text field content.
13///
14/// This represents the text, selection, and composition state at a point in time.
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub struct TextFieldValue {
17    /// The text content
18    pub text: String,
19    /// Current selection or cursor position
20    pub selection: TextRange,
21    /// IME composition range, if any
22    pub composition: Option<TextRange>,
23}
24
25impl TextFieldValue {
26    /// Creates a new value with the given text and cursor at end.
27    pub fn new(text: impl Into<String>) -> Self {
28        let text = text.into();
29        let len = text.len();
30        Self {
31            text,
32            selection: TextRange::cursor(len),
33            composition: None,
34        }
35    }
36
37    /// Creates a value with specified text and selection.
38    pub fn with_selection(text: impl Into<String>, selection: TextRange) -> Self {
39        let text = text.into();
40        let selection = selection.coerce_in(text.len());
41        Self {
42            text,
43            selection,
44            composition: None,
45        }
46    }
47}
48
49type ChangeListener = Box<dyn Fn(&TextFieldValue)>;
50
51const UNDO_CAPACITY: usize = 100;
52
53const UNDO_COALESCE_MS: u128 = 1000;
54
55/// Spare room in an edit's copy of the text, so a typed character does not
56/// grow the copy and move the whole text a second time.
57const EDIT_HEADROOM_BYTES: usize = 64;
58
59/// What an edit changed, against the value before it.
60struct EditChange {
61    text_changed: bool,
62    /// A one-byte insert right after the cursor that does not end in
63    /// whitespace: typing that joins the pending undo group.
64    continues_typing: bool,
65}
66
67impl EditChange {
68    fn between(before: &TextFieldValue, after: &TextFieldBuffer) -> Option<Self> {
69        let selection = after.selection();
70        let text_changed = after.text() != before.text;
71        if !text_changed
72            && selection == before.selection
73            && after.composition() == before.composition
74        {
75            return None;
76        }
77        let single_byte_insert = after.len() == before.text.len() + 1;
78        let cursor_followed = selection.start == before.selection.start + 1
79            || selection.start == before.selection.end + 1;
80        Some(Self {
81            text_changed,
82            continues_typing: single_byte_insert
83                && cursor_followed
84                && !after.text().ends_with(char::is_whitespace),
85        })
86    }
87}
88
89pub struct TextFieldStateInner {
90    is_editing: bool,
91    listeners: Vec<ChangeListener>,
92    undo_stack: VecDeque<TextFieldValue>,
93    redo_stack: VecDeque<TextFieldValue>,
94    desired_column: Cell<Option<usize>>,
95    last_edit_time: Cell<Option<web_time::Instant>>,
96    pending_undo_snapshot: RefCell<Option<TextFieldValue>>,
97}
98
99struct EditGuard<'a> {
100    inner: &'a RefCell<TextFieldStateInner>,
101}
102
103impl<'a> EditGuard<'a> {
104    fn new(inner: &'a RefCell<TextFieldStateInner>) -> Result<Self, ()> {
105        {
106            let borrowed = inner.borrow();
107            if borrowed.is_editing {
108                return Err(());
109            }
110        }
111        inner.borrow_mut().is_editing = true;
112        Ok(Self { inner })
113    }
114}
115
116impl Drop for EditGuard<'_> {
117    fn drop(&mut self) {
118        self.inner.borrow_mut().is_editing = false;
119    }
120}
121
122/// Observable state holder for text field content.
123///
124/// This is the primary API for managing text field state. All edits go through
125/// the [`edit`](Self::edit) method which provides a mutable buffer.
126///
127/// # Example
128///
129/// ```ignore
130/// use cranpose_foundation::text::TextFieldState;
131///
132/// let state = TextFieldState::new("Hello");
133///
134/// // Edit the text
135/// state.edit(|buffer| {
136///     buffer.place_cursor_at_end();
137///     buffer.insert(", World!");
138/// });
139///
140/// assert_eq!(state.text(), "Hello, World!");
141/// ```
142///
143/// # Thread Safety
144///
145/// `TextFieldState` uses `Rc<RefCell<...>>` internally and is not thread-safe.
146/// It should only be used from the main thread.
147#[derive(Clone, Copy)]
148pub struct TextFieldState {
149    /// Internal state for editing machinery.
150    /// Public for cross-crate pointer-based identity comparison (Hash).
151    pub inner: MutableState<Rc<RefCell<TextFieldStateInner>>>,
152
153    value: MutableState<TextFieldValue>,
154}
155
156impl std::fmt::Debug for TextFieldState {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        self.value.read(|v| {
159            f.debug_struct("TextFieldState")
160                .field("text", &v.text)
161                .field("selection", &v.selection)
162                .finish()
163        })
164    }
165}
166
167impl TextFieldState {
168    /// Creates a new text field state with the given initial text.
169    pub fn new(initial_text: impl Into<String>) -> Self {
170        let initial_value = TextFieldValue::new(initial_text);
171        let runtime = cranpose_core::current_runtime_handle()
172            .expect("TextFieldState::new requires an active runtime");
173        Self {
174            inner: MutableState::with_runtime(
175                Rc::new(RefCell::new(TextFieldStateInner {
176                    is_editing: false,
177                    listeners: Vec::new(),
178                    undo_stack: VecDeque::new(),
179                    redo_stack: VecDeque::new(),
180                    desired_column: Cell::new(None),
181                    last_edit_time: Cell::new(None),
182                    pending_undo_snapshot: RefCell::new(None),
183                })),
184                runtime.clone(),
185            ),
186            value: MutableState::with_runtime(initial_value, runtime),
187        }
188    }
189
190    /// Creates a state with initial text and selection.
191    pub fn with_selection(initial_text: impl Into<String>, selection: TextRange) -> Self {
192        let initial_value = TextFieldValue::with_selection(initial_text, selection);
193        let runtime = cranpose_core::current_runtime_handle()
194            .expect("TextFieldState::with_selection requires an active runtime");
195        Self {
196            inner: MutableState::with_runtime(
197                Rc::new(RefCell::new(TextFieldStateInner {
198                    is_editing: false,
199                    listeners: Vec::new(),
200                    undo_stack: VecDeque::new(),
201                    redo_stack: VecDeque::new(),
202                    desired_column: Cell::new(None),
203                    last_edit_time: Cell::new(None),
204                    pending_undo_snapshot: RefCell::new(None),
205                })),
206                runtime.clone(),
207            ),
208            value: MutableState::with_runtime(initial_value, runtime),
209        }
210    }
211
212    fn inner(&self) -> Rc<RefCell<TextFieldStateInner>> {
213        self.inner.get_non_reactive()
214    }
215
216    pub fn id(&self) -> u64 {
217        let mut hasher = std::collections::hash_map::DefaultHasher::new();
218        self.inner.runtime_state_id().hash(&mut hasher);
219        hasher.finish()
220    }
221
222    /// Gets the desired column for up/down navigation, in characters
223    /// (extended grapheme clusters) from the start of the line.
224    pub fn desired_column(&self) -> Option<usize> {
225        self.inner().borrow().desired_column.get()
226    }
227
228    /// Sets the desired column for up/down navigation, in characters
229    /// (extended grapheme clusters) from the start of the line.
230    pub fn set_desired_column(&self, col: Option<usize>) {
231        self.inner().borrow().desired_column.set(col);
232    }
233
234    /// Returns the current text content.
235    /// Creates composition dependency when read during composition.
236    pub fn text(&self) -> String {
237        self.value.read(|v| v.text.clone())
238    }
239
240    /// Returns the current selection range.
241    pub fn selection(&self) -> TextRange {
242        self.value.read(|v| v.selection)
243    }
244
245    /// Returns the current composition (IME) range, if any.
246    pub fn composition(&self) -> Option<TextRange> {
247        self.value.read(|v| v.composition)
248    }
249
250    /// Copies the selected text without modifying the clipboard.
251    /// Returns the selected text, or None if no selection.
252    pub fn copy_selection(&self) -> Option<String> {
253        self.value.read(|v| {
254            let selection = v.selection;
255            if selection.collapsed() {
256                return None;
257            }
258            let start = selection.min();
259            let end = selection.max();
260            Some(v.text[start..end].to_string())
261        })
262    }
263
264    /// Returns the current value snapshot.
265    /// Creates composition dependency when read during composition.
266    pub fn value(&self) -> TextFieldValue {
267        self.value.read(Clone::clone)
268    }
269
270    /// Adds a listener that is called when the value changes.
271    ///
272    /// Returns the listener index for removal.
273    pub fn add_listener(&self, listener: impl Fn(&TextFieldValue) + 'static) -> usize {
274        let inner_state = self.inner();
275        let mut inner = inner_state.borrow_mut();
276        let index = inner.listeners.len();
277        inner.listeners.push(Box::new(listener));
278        index
279    }
280
281    /// Sets the selection directly without going through undo stack.
282    /// Use this for transient selection changes like during drag selection.
283    pub fn set_selection(&self, selection: TextRange) {
284        let new_value = self.value.read(|v| {
285            let len = v.text.len();
286            TextFieldValue {
287                text: v.text.clone(),
288                selection: selection.coerce_in(len),
289                composition: v.composition,
290            }
291        });
292        self.value.set(new_value);
293    }
294
295    /// Returns true if undo is available.
296    pub fn can_undo(&self) -> bool {
297        !self.inner().borrow().undo_stack.is_empty()
298    }
299
300    /// Returns true if redo is available.
301    pub fn can_redo(&self) -> bool {
302        !self.inner().borrow().redo_stack.is_empty()
303    }
304
305    /// Undoes the last edit.
306    /// Returns true if undo was performed.
307    pub fn undo(&self) -> bool {
308        self.flush_undo_group();
309
310        let inner_state = self.inner();
311        let mut inner = inner_state.borrow_mut();
312        if let Some(previous_state) = inner.undo_stack.pop_back() {
313            let current = self.value.read(Clone::clone);
314            inner.redo_stack.push_back(current);
315            inner.last_edit_time.set(None);
316            drop(inner);
317            self.value.set(previous_state);
318            true
319        } else {
320            false
321        }
322    }
323
324    /// Redoes the last undone edit.
325    /// Returns true if redo was performed.
326    pub fn redo(&self) -> bool {
327        let inner_state = self.inner();
328        let mut inner = inner_state.borrow_mut();
329        if let Some(redo_state) = inner.redo_stack.pop_back() {
330            let current = self.value.read(Clone::clone);
331            inner.undo_stack.push_back(current);
332            drop(inner);
333            self.value.set(redo_state);
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Edits the text field content.
341    ///
342    /// The provided closure receives a mutable buffer that can be used to
343    /// modify the text and selection. After the closure returns, the changes
344    /// are committed and listeners are notified.
345    ///
346    /// # Undo Coalescing
347    ///
348    /// Consecutive character insertions within the coalescing timeout are grouped
349    /// into a single undo entry. The group breaks when:
350    /// - Timeout expires (1 second between edits)
351    /// - Whitespace or newline is typed
352    /// - Cursor position jumps (non-consecutive insert)
353    /// - A non-insert operation occurs (delete, paste multi-char, etc.)
354    ///
355    /// Returns `false` if another edit is already in progress.
356    pub fn edit<F>(&self, f: F) -> bool
357    where
358        F: FnOnce(&mut TextFieldBuffer),
359    {
360        let inner = self.inner();
361        let Ok(guard) = EditGuard::new(&inner) else {
362            return false;
363        };
364
365        let mut buffer = self.value.read(|current| {
366            let mut text = String::with_capacity(current.text.len() + EDIT_HEADROOM_BYTES);
367            text.push_str(&current.text);
368            let mut buffer = TextFieldBuffer::with_selection(text, current.selection);
369            buffer.set_composition(current.composition);
370            buffer
371        });
372
373        f(&mut buffer);
374
375        let Some(change) = self
376            .value
377            .read(|current| EditChange::between(current, &buffer))
378        else {
379            return true;
380        };
381
382        // Undo history records text changes, as Compose's does. A move of the
383        // cursor or selection only ends the typing group before it.
384        if !change.text_changed {
385            inner.borrow().last_edit_time.set(None);
386        } else {
387            let now = web_time::Instant::now();
388            let mut inner = inner.borrow_mut();
389            let timeout_expired = inner
390                .last_edit_time
391                .get()
392                .is_none_or(|last| now.duration_since(last).as_millis() > UNDO_COALESCE_MS);
393            // The value before this edit, while the state still holds it.
394            let snapshot = || Some(self.value.read(Clone::clone));
395            if timeout_expired || !change.continues_typing {
396                if let Some(pending) = inner.pending_undo_snapshot.take() {
397                    if inner.undo_stack.len() >= UNDO_CAPACITY {
398                        inner.undo_stack.pop_front();
399                    }
400                    inner.undo_stack.push_back(pending);
401                }
402                inner.pending_undo_snapshot.replace(snapshot());
403            } else if inner.pending_undo_snapshot.borrow().is_none() {
404                inner.pending_undo_snapshot.replace(snapshot());
405            }
406            inner.redo_stack.clear();
407            inner.last_edit_time.set(Some(now));
408        }
409
410        self.value.set(buffer.into_value());
411
412        drop(guard);
413
414        let listener_count = inner.borrow().listeners.len();
415        if listener_count > 0 {
416            let value = self.value.get_non_reactive();
417            for i in 0..listener_count {
418                let inner = inner.borrow();
419                if i < inner.listeners.len() {
420                    (inner.listeners[i])(&value);
421                }
422            }
423        }
424        true
425    }
426
427    /// Flushes any pending undo snapshot to the undo stack.
428    /// Call this when a coalescing break is desired (e.g., focus lost).
429    pub fn flush_undo_group(&self) {
430        let inner_state = self.inner();
431        let inner = inner_state.borrow();
432        if let Some(snapshot) = inner.pending_undo_snapshot.take() {
433            drop(inner);
434            let inner_state = self.inner();
435            let mut inner = inner_state.borrow_mut();
436            if inner.undo_stack.len() >= UNDO_CAPACITY {
437                inner.undo_stack.pop_front();
438            }
439            inner.undo_stack.push_back(snapshot);
440        }
441    }
442
443    /// Sets the text and places cursor at end.
444    pub fn set_text(&self, text: impl Into<String>) -> bool {
445        let text = text.into();
446        self.edit(|buffer| {
447            buffer.clear();
448            buffer.insert(&text);
449        })
450    }
451}
452
453impl Default for TextFieldState {
454    fn default() -> Self {
455        Self::new("")
456    }
457}
458
459impl PartialEq for TextFieldState {
460    fn eq(&self, other: &Self) -> bool {
461        self.inner == other.inner
462    }
463}
464
465#[cfg(test)]
466#[path = "tests/state_tests.rs"]
467mod tests;