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    pub fn id(&self) -> u64 {
220        let mut hasher = std::collections::hash_map::DefaultHasher::new();
221        self.inner.runtime_state_id().hash(&mut hasher);
222        hasher.finish()
223    }
224
225    /// Where up and down arrows keep the caret: its x within its line, in
226    /// layout pixels, from before the first of a run of vertical moves.
227    pub fn desired_x(&self) -> Option<f32> {
228        self.inner().borrow().desired_x.get()
229    }
230
231    /// Sets or clears the x that vertical moves keep; any other edit or
232    /// caret move clears it.
233    pub fn set_desired_x(&self, x: Option<f32>) {
234        self.inner().borrow().desired_x.set(x);
235    }
236
237    /// Counts the changes to the text: it differs after each one, so a
238    /// reader that keeps something built from the text knows when to build
239    /// it again without comparing the text.
240    pub fn text_revision(&self) -> u64 {
241        self.inner().borrow().text_revision.get()
242    }
243
244    fn note_text_change(&self) {
245        let inner = self.inner();
246        let inner = inner.borrow();
247        inner
248            .text_revision
249            .set(inner.text_revision.get().wrapping_add(1));
250    }
251
252    /// Reads the text in place through `f`, which must not edit this state.
253    /// Creates a composition dependency when read during composition.
254    pub fn with_text<R>(&self, f: impl FnOnce(&str) -> R) -> R {
255        self.value.read(|value| f(&value.text))
256    }
257
258    /// Returns the current text content.
259    /// Creates composition dependency when read during composition.
260    pub fn text(&self) -> String {
261        self.value.read(|v| v.text.clone())
262    }
263
264    /// Returns the current selection range.
265    pub fn selection(&self) -> TextRange {
266        self.value.read(|v| v.selection)
267    }
268
269    /// Returns the current composition (IME) range, if any.
270    pub fn composition(&self) -> Option<TextRange> {
271        self.value.read(|v| v.composition)
272    }
273
274    /// Copies the selected text without modifying the clipboard.
275    /// Returns the selected text, or None if no selection.
276    pub fn copy_selection(&self) -> Option<String> {
277        self.value.read(|v| {
278            let selection = v.selection;
279            if selection.collapsed() {
280                return None;
281            }
282            let start = selection.min();
283            let end = selection.max();
284            Some(v.text[start..end].to_string())
285        })
286    }
287
288    /// Returns the current value snapshot.
289    /// Creates composition dependency when read during composition.
290    pub fn value(&self) -> TextFieldValue {
291        self.value.read(Clone::clone)
292    }
293
294    /// Adds a listener that is called when the value changes.
295    ///
296    /// Returns the listener index for removal.
297    pub fn add_listener(&self, listener: impl Fn(&TextFieldValue) + 'static) -> usize {
298        let inner_state = self.inner();
299        let mut inner = inner_state.borrow_mut();
300        let index = inner.listeners.len();
301        inner.listeners.push(Box::new(listener));
302        index
303    }
304
305    /// Sets the selection directly without going through undo stack.
306    /// Use this for transient selection changes like during drag selection.
307    pub fn set_selection(&self, selection: TextRange) {
308        let new_value = self.value.read(|v| {
309            let len = v.text.len();
310            TextFieldValue {
311                text: v.text.clone(),
312                selection: selection.coerce_in(len),
313                composition: v.composition,
314            }
315        });
316        self.value.set(new_value);
317    }
318
319    /// Returns true if undo is available.
320    pub fn can_undo(&self) -> bool {
321        !self.inner().borrow().undo_stack.is_empty()
322    }
323
324    /// Returns true if redo is available.
325    pub fn can_redo(&self) -> bool {
326        !self.inner().borrow().redo_stack.is_empty()
327    }
328
329    /// Undoes the last edit.
330    /// Returns true if undo was performed.
331    pub fn undo(&self) -> bool {
332        self.flush_undo_group();
333
334        let inner_state = self.inner();
335        let mut inner = inner_state.borrow_mut();
336        if let Some(previous_state) = inner.undo_stack.pop_back() {
337            let current = self.value.read(Clone::clone);
338            inner.redo_stack.push_back(current);
339            inner.last_edit_time.set(None);
340            drop(inner);
341            self.value.set(previous_state);
342            self.note_text_change();
343            true
344        } else {
345            false
346        }
347    }
348
349    /// Redoes the last undone edit.
350    /// Returns true if redo was performed.
351    pub fn redo(&self) -> bool {
352        let inner_state = self.inner();
353        let mut inner = inner_state.borrow_mut();
354        if let Some(redo_state) = inner.redo_stack.pop_back() {
355            let current = self.value.read(Clone::clone);
356            inner.undo_stack.push_back(current);
357            drop(inner);
358            self.value.set(redo_state);
359            self.note_text_change();
360            true
361        } else {
362            false
363        }
364    }
365
366    /// Edits the text field content.
367    ///
368    /// The provided closure receives a mutable buffer that can be used to
369    /// modify the text and selection. After the closure returns, the changes
370    /// are committed and listeners are notified.
371    ///
372    /// # Undo Coalescing
373    ///
374    /// Consecutive character insertions within the coalescing timeout are grouped
375    /// into a single undo entry. The group breaks when:
376    /// - Timeout expires (1 second between edits)
377    /// - Whitespace or newline is typed
378    /// - Cursor position jumps (non-consecutive insert)
379    /// - A non-insert operation occurs (delete, paste multi-char, etc.)
380    ///
381    /// Returns `false` if another edit is already in progress.
382    pub fn edit<F>(&self, f: F) -> bool
383    where
384        F: FnOnce(&mut TextFieldBuffer),
385    {
386        self.edit_as_user(None, f)
387    }
388
389    /// Edits as a person's input does: `f`, then `transformation` when `f`
390    /// changed the text. Text field input edits through here, and app code
391    /// through [`edit`](Self::edit).
392    pub fn edit_as_user<F>(&self, transformation: Option<&InputTransformation>, f: F) -> bool
393    where
394        F: FnOnce(&mut TextFieldBuffer),
395    {
396        let inner = self.inner();
397        let Ok(guard) = EditGuard::new(&inner) else {
398            return false;
399        };
400
401        let mut buffer = self.value.read(|current| {
402            let mut text = String::with_capacity(current.text.len() + EDIT_HEADROOM_BYTES);
403            text.push_str(&current.text);
404            let mut buffer = TextFieldBuffer::with_selection(text, current.selection);
405            buffer.set_composition(current.composition);
406            buffer
407        });
408
409        f(&mut buffer);
410        if let Some(transformation) = transformation {
411            self.value.read(|current| {
412                if buffer.text() != current.text {
413                    transformation.transform(current, &mut buffer);
414                }
415            });
416        }
417
418        let Some(change) = self
419            .value
420            .read(|current| EditChange::between(current, &buffer))
421        else {
422            return true;
423        };
424
425        // Undo history records text changes, as Compose's does. A move of the
426        // cursor or selection only ends the typing group before it.
427        if !change.text_changed {
428            inner.borrow().last_edit_time.set(None);
429        } else {
430            let now = web_time::Instant::now();
431            let mut inner = inner.borrow_mut();
432            let timeout_expired = inner
433                .last_edit_time
434                .get()
435                .is_none_or(|last| now.duration_since(last).as_millis() > UNDO_COALESCE_MS);
436            // The value before this edit, while the state still holds it.
437            let snapshot = || Some(self.value.read(Clone::clone));
438            if timeout_expired || !change.continues_typing {
439                if let Some(pending) = inner.pending_undo_snapshot.take() {
440                    if inner.undo_stack.len() >= UNDO_CAPACITY {
441                        inner.undo_stack.pop_front();
442                    }
443                    inner.undo_stack.push_back(pending);
444                }
445                inner.pending_undo_snapshot.replace(snapshot());
446            } else if inner.pending_undo_snapshot.borrow().is_none() {
447                inner.pending_undo_snapshot.replace(snapshot());
448            }
449            inner.redo_stack.clear();
450            inner.last_edit_time.set(Some(now));
451        }
452
453        self.value.set(buffer.into_value());
454        if change.text_changed {
455            self.note_text_change();
456        }
457
458        drop(guard);
459
460        let listener_count = inner.borrow().listeners.len();
461        if listener_count > 0 {
462            let value = self.value.get_non_reactive();
463            for i in 0..listener_count {
464                let inner = inner.borrow();
465                if i < inner.listeners.len() {
466                    (inner.listeners[i])(&value);
467                }
468            }
469        }
470        true
471    }
472
473    /// Flushes any pending undo snapshot to the undo stack.
474    /// Call this when a coalescing break is desired (e.g., focus lost).
475    pub fn flush_undo_group(&self) {
476        let inner_state = self.inner();
477        let inner = inner_state.borrow();
478        if let Some(snapshot) = inner.pending_undo_snapshot.take() {
479            drop(inner);
480            let inner_state = self.inner();
481            let mut inner = inner_state.borrow_mut();
482            if inner.undo_stack.len() >= UNDO_CAPACITY {
483                inner.undo_stack.pop_front();
484            }
485            inner.undo_stack.push_back(snapshot);
486        }
487    }
488
489    /// Sets the text and places cursor at end.
490    pub fn set_text(&self, text: impl Into<String>) -> bool {
491        let text = text.into();
492        self.edit(|buffer| {
493            buffer.clear();
494            buffer.insert(&text);
495        })
496    }
497}
498
499impl Default for TextFieldState {
500    fn default() -> Self {
501        Self::new("")
502    }
503}
504
505impl PartialEq for TextFieldState {
506    fn eq(&self, other: &Self) -> bool {
507        self.inner == other.inner
508    }
509}
510
511#[cfg(test)]
512#[path = "tests/state_tests.rs"]
513mod tests;