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::{InputTransformation, 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_x: Cell<Option<f32>>,
95    text_revision: Cell<u64>,
96    last_edit_time: Cell<Option<web_time::Instant>>,
97    pending_undo_snapshot: RefCell<Option<TextFieldValue>>,
98}
99
100struct EditGuard<'a> {
101    inner: &'a RefCell<TextFieldStateInner>,
102}
103
104impl<'a> EditGuard<'a> {
105    fn new(inner: &'a RefCell<TextFieldStateInner>) -> Result<Self, ()> {
106        {
107            let borrowed = inner.borrow();
108            if borrowed.is_editing {
109                return Err(());
110            }
111        }
112        inner.borrow_mut().is_editing = true;
113        Ok(Self { inner })
114    }
115}
116
117impl Drop for EditGuard<'_> {
118    fn drop(&mut self) {
119        self.inner.borrow_mut().is_editing = false;
120    }
121}
122
123/// Observable state holder for text field content.
124///
125/// This is the primary API for managing text field state. All edits go through
126/// the [`edit`](Self::edit) method which provides a mutable buffer.
127///
128/// # Example
129///
130/// ```ignore
131/// use cranpose_foundation::text::TextFieldState;
132///
133/// let state = TextFieldState::new("Hello");
134///
135/// // Edit the text
136/// state.edit(|buffer| {
137///     buffer.place_cursor_at_end();
138///     buffer.insert(", World!");
139/// });
140///
141/// assert_eq!(state.text(), "Hello, World!");
142/// ```
143///
144/// # Thread Safety
145///
146/// `TextFieldState` uses `Rc<RefCell<...>>` internally and is not thread-safe.
147/// It should only be used from the main thread.
148#[derive(Clone, Copy)]
149pub struct TextFieldState {
150    /// Internal state for editing machinery.
151    /// Public for cross-crate pointer-based identity comparison (Hash).
152    pub inner: MutableState<Rc<RefCell<TextFieldStateInner>>>,
153
154    value: MutableState<TextFieldValue>,
155}
156
157impl std::fmt::Debug for TextFieldState {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        self.value.read(|v| {
160            f.debug_struct("TextFieldState")
161                .field("text", &v.text)
162                .field("selection", &v.selection)
163                .finish()
164        })
165    }
166}
167
168impl TextFieldState {
169    /// Creates a new text field state with the given initial text.
170    pub fn new(initial_text: impl Into<String>) -> Self {
171        let initial_value = TextFieldValue::new(initial_text);
172        let runtime = cranpose_core::current_runtime_handle()
173            .expect("TextFieldState::new requires an active runtime");
174        Self {
175            inner: MutableState::with_runtime(
176                Rc::new(RefCell::new(TextFieldStateInner {
177                    is_editing: false,
178                    listeners: Vec::new(),
179                    undo_stack: VecDeque::new(),
180                    redo_stack: VecDeque::new(),
181                    desired_x: Cell::new(None),
182                    text_revision: Cell::new(0),
183                    last_edit_time: Cell::new(None),
184                    pending_undo_snapshot: RefCell::new(None),
185                })),
186                runtime.clone(),
187            ),
188            value: MutableState::with_runtime(initial_value, runtime),
189        }
190    }
191
192    /// Creates a state with initial text and selection.
193    pub fn with_selection(initial_text: impl Into<String>, selection: TextRange) -> Self {
194        let initial_value = TextFieldValue::with_selection(initial_text, selection);
195        let runtime = cranpose_core::current_runtime_handle()
196            .expect("TextFieldState::with_selection requires an active runtime");
197        Self {
198            inner: MutableState::with_runtime(
199                Rc::new(RefCell::new(TextFieldStateInner {
200                    is_editing: false,
201                    listeners: Vec::new(),
202                    undo_stack: VecDeque::new(),
203                    redo_stack: VecDeque::new(),
204                    desired_x: Cell::new(None),
205                    text_revision: Cell::new(0),
206                    last_edit_time: Cell::new(None),
207                    pending_undo_snapshot: RefCell::new(None),
208                })),
209                runtime.clone(),
210            ),
211            value: MutableState::with_runtime(initial_value, runtime),
212        }
213    }
214
215    fn inner(&self) -> Rc<RefCell<TextFieldStateInner>> {
216        self.inner.get_non_reactive()
217    }
218
219    /// Identifies this state. Clones of the state report the same id.
220    pub fn id(&self) -> u64 {
221        let mut hasher = std::collections::hash_map::DefaultHasher::new();
222        self.inner.runtime_state_id().hash(&mut hasher);
223        hasher.finish()
224    }
225
226    /// Where up and down arrows keep the caret: its x within its line, in
227    /// layout pixels, from before the first of a run of vertical moves.
228    pub fn desired_x(&self) -> Option<f32> {
229        self.inner().borrow().desired_x.get()
230    }
231
232    /// Sets or clears the x that vertical moves keep; any other edit or
233    /// caret move clears it.
234    pub fn set_desired_x(&self, x: Option<f32>) {
235        self.inner().borrow().desired_x.set(x);
236    }
237
238    /// Counts the changes to the text: it differs after each one, so a
239    /// reader that keeps something built from the text knows when to build
240    /// it again without comparing the text.
241    pub fn text_revision(&self) -> u64 {
242        self.inner().borrow().text_revision.get()
243    }
244
245    fn note_text_change(&self) {
246        let inner = self.inner();
247        let inner = inner.borrow();
248        inner
249            .text_revision
250            .set(inner.text_revision.get().wrapping_add(1));
251    }
252
253    /// Reads the text in place through `f`, which must not edit this state.
254    /// Creates a composition dependency when read during composition.
255    pub fn with_text<R>(&self, f: impl FnOnce(&str) -> R) -> R {
256        self.value.read(|value| f(&value.text))
257    }
258
259    /// Returns the current text content.
260    /// Creates composition dependency when read during composition.
261    pub fn text(&self) -> String {
262        self.value.read(|v| v.text.clone())
263    }
264
265    /// Returns the current selection range.
266    pub fn selection(&self) -> TextRange {
267        self.value.read(|v| v.selection)
268    }
269
270    /// Returns the current composition (IME) range, if any.
271    pub fn composition(&self) -> Option<TextRange> {
272        self.value.read(|v| v.composition)
273    }
274
275    /// Copies the selected text without modifying the clipboard.
276    /// Returns the selected text, or None if no selection.
277    pub fn copy_selection(&self) -> Option<String> {
278        self.value.read(|v| {
279            let selection = v.selection;
280            if selection.collapsed() {
281                return None;
282            }
283            let start = selection.min();
284            let end = selection.max();
285            Some(v.text[start..end].to_string())
286        })
287    }
288
289    /// Returns the current value snapshot.
290    /// Creates composition dependency when read during composition.
291    pub fn value(&self) -> TextFieldValue {
292        self.value.read(Clone::clone)
293    }
294
295    /// Adds a listener that is called when the value changes.
296    ///
297    /// Returns the listener index for removal.
298    pub fn add_listener(&self, listener: impl Fn(&TextFieldValue) + 'static) -> usize {
299        let inner_state = self.inner();
300        let mut inner = inner_state.borrow_mut();
301        let index = inner.listeners.len();
302        inner.listeners.push(Box::new(listener));
303        index
304    }
305
306    /// Sets the selection directly without going through undo stack.
307    /// Use this for transient selection changes like during drag selection.
308    pub fn set_selection(&self, selection: TextRange) {
309        let new_value = self.value.read(|v| {
310            let len = v.text.len();
311            TextFieldValue {
312                text: v.text.clone(),
313                selection: selection.coerce_in(len),
314                composition: v.composition,
315            }
316        });
317        self.value.set(new_value);
318    }
319
320    /// Returns true if undo is available.
321    pub fn can_undo(&self) -> bool {
322        !self.inner().borrow().undo_stack.is_empty()
323    }
324
325    /// Returns true if redo is available.
326    pub fn can_redo(&self) -> bool {
327        !self.inner().borrow().redo_stack.is_empty()
328    }
329
330    /// Undoes the last edit.
331    /// Returns true if undo was performed.
332    pub fn undo(&self) -> bool {
333        self.flush_undo_group();
334
335        let inner_state = self.inner();
336        let mut inner = inner_state.borrow_mut();
337        if let Some(previous_state) = inner.undo_stack.pop_back() {
338            let current = self.value.read(Clone::clone);
339            inner.redo_stack.push_back(current);
340            inner.last_edit_time.set(None);
341            drop(inner);
342            self.value.set(previous_state);
343            self.note_text_change();
344            true
345        } else {
346            false
347        }
348    }
349
350    /// Redoes the last undone edit.
351    /// Returns true if redo was performed.
352    pub fn redo(&self) -> bool {
353        let inner_state = self.inner();
354        let mut inner = inner_state.borrow_mut();
355        if let Some(redo_state) = inner.redo_stack.pop_back() {
356            let current = self.value.read(Clone::clone);
357            inner.undo_stack.push_back(current);
358            drop(inner);
359            self.value.set(redo_state);
360            self.note_text_change();
361            true
362        } else {
363            false
364        }
365    }
366
367    /// Edits the text field content.
368    ///
369    /// The provided closure receives a mutable buffer that can be used to
370    /// modify the text and selection. After the closure returns, the changes
371    /// are committed and listeners are notified.
372    ///
373    /// # Undo Coalescing
374    ///
375    /// Consecutive character insertions within the coalescing timeout are grouped
376    /// into a single undo entry. The group breaks when:
377    /// - Timeout expires (1 second between edits)
378    /// - Whitespace or newline is typed
379    /// - Cursor position jumps (non-consecutive insert)
380    /// - A non-insert operation occurs (delete, paste multi-char, etc.)
381    ///
382    /// Returns `false` if another edit is already in progress.
383    pub fn edit<F>(&self, f: F) -> bool
384    where
385        F: FnOnce(&mut TextFieldBuffer),
386    {
387        self.edit_as_user(None, f)
388    }
389
390    /// Edits as a person's input does: `f`, then `transformation` when `f`
391    /// changed the text. Text field input edits through here, and app code
392    /// through [`edit`](Self::edit).
393    pub fn edit_as_user<F>(&self, transformation: Option<&InputTransformation>, f: F) -> bool
394    where
395        F: FnOnce(&mut TextFieldBuffer),
396    {
397        let inner = self.inner();
398        let Ok(guard) = EditGuard::new(&inner) else {
399            return false;
400        };
401
402        let mut buffer = self.value.read(|current| {
403            let mut text = String::with_capacity(current.text.len() + EDIT_HEADROOM_BYTES);
404            text.push_str(&current.text);
405            let mut buffer = TextFieldBuffer::with_selection(text, current.selection);
406            buffer.set_composition(current.composition);
407            buffer
408        });
409
410        f(&mut buffer);
411        if let Some(transformation) = transformation {
412            self.value.read(|current| {
413                if buffer.text() != current.text {
414                    transformation.transform(current, &mut buffer);
415                }
416            });
417        }
418
419        let Some(change) = self
420            .value
421            .read(|current| EditChange::between(current, &buffer))
422        else {
423            return true;
424        };
425
426        // Undo history records text changes, as Compose's does. A move of the
427        // cursor or selection only ends the typing group before it.
428        if !change.text_changed {
429            inner.borrow().last_edit_time.set(None);
430        } else {
431            let now = web_time::Instant::now();
432            let mut inner = inner.borrow_mut();
433            let timeout_expired = inner
434                .last_edit_time
435                .get()
436                .is_none_or(|last| now.duration_since(last).as_millis() > UNDO_COALESCE_MS);
437            // The value before this edit, while the state still holds it.
438            let snapshot = || Some(self.value.read(Clone::clone));
439            if timeout_expired || !change.continues_typing {
440                if let Some(pending) = inner.pending_undo_snapshot.take() {
441                    if inner.undo_stack.len() >= UNDO_CAPACITY {
442                        inner.undo_stack.pop_front();
443                    }
444                    inner.undo_stack.push_back(pending);
445                }
446                inner.pending_undo_snapshot.replace(snapshot());
447            } else if inner.pending_undo_snapshot.borrow().is_none() {
448                inner.pending_undo_snapshot.replace(snapshot());
449            }
450            inner.redo_stack.clear();
451            inner.last_edit_time.set(Some(now));
452        }
453
454        self.value.set(buffer.into_value());
455        if change.text_changed {
456            self.note_text_change();
457        }
458
459        drop(guard);
460
461        let listener_count = inner.borrow().listeners.len();
462        if listener_count > 0 {
463            let value = self.value.get_non_reactive();
464            for i in 0..listener_count {
465                let inner = inner.borrow();
466                if i < inner.listeners.len() {
467                    (inner.listeners[i])(&value);
468                }
469            }
470        }
471        true
472    }
473
474    /// Flushes any pending undo snapshot to the undo stack.
475    /// Call this when a coalescing break is desired (e.g., focus lost).
476    pub fn flush_undo_group(&self) {
477        let inner_state = self.inner();
478        let inner = inner_state.borrow();
479        if let Some(snapshot) = inner.pending_undo_snapshot.take() {
480            drop(inner);
481            let inner_state = self.inner();
482            let mut inner = inner_state.borrow_mut();
483            if inner.undo_stack.len() >= UNDO_CAPACITY {
484                inner.undo_stack.pop_front();
485            }
486            inner.undo_stack.push_back(snapshot);
487        }
488    }
489
490    /// Sets the text and places cursor at end.
491    pub fn set_text(&self, text: impl Into<String>) -> bool {
492        let text = text.into();
493        self.edit(|buffer| {
494            buffer.clear();
495            buffer.insert(&text);
496        })
497    }
498}
499
500impl Default for TextFieldState {
501    fn default() -> Self {
502        Self::new("")
503    }
504}
505
506impl PartialEq for TextFieldState {
507    fn eq(&self, other: &Self) -> bool {
508        self.inner == other.inner
509    }
510}
511
512#[cfg(test)]
513#[path = "tests/state_tests.rs"]
514mod tests;