Skip to main content

cranpose_foundation/text/
state.rs

1//! Observable state holder for text field content.
2//!
3//! Matches Jetpack Compose's `TextFieldState` from
4//! `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/input/TextFieldState.kt`.
5
6use super::{TextFieldBuffer, TextRange};
7use std::cell::{Cell, RefCell};
8use std::collections::VecDeque;
9use std::rc::Rc;
10
11/// Immutable snapshot of text field content.
12///
13/// This represents the text, selection, and composition state at a point in time.
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct TextFieldValue {
16    /// The text content
17    pub text: String,
18    /// Current selection or cursor position
19    pub selection: TextRange,
20    /// IME composition range, if any
21    pub composition: Option<TextRange>,
22}
23
24impl TextFieldValue {
25    /// Creates a new value with the given text and cursor at end.
26    pub fn new(text: impl Into<String>) -> Self {
27        let text = text.into();
28        let len = text.len();
29        Self {
30            text,
31            selection: TextRange::cursor(len),
32            composition: None,
33        }
34    }
35
36    /// Creates a value with specified text and selection.
37    pub fn with_selection(text: impl Into<String>, selection: TextRange) -> Self {
38        let text = text.into();
39        let selection = selection.coerce_in(text.len());
40        Self {
41            text,
42            selection,
43            composition: None,
44        }
45    }
46}
47
48type ChangeListener = Box<dyn Fn(&TextFieldValue)>;
49
50/// Maximum capacity for undo stack
51const UNDO_CAPACITY: usize = 100;
52
53/// Timeout for undo coalescing in milliseconds.
54/// Consecutive edits within this window are grouped into a single undo.
55const UNDO_COALESCE_MS: u128 = 1000;
56
57/// Inner state for TextFieldState - contains editing machinery ONLY.
58/// Value storage is handled by MutableState.
59pub struct TextFieldStateInner {
60    /// Flag to prevent concurrent edits  
61    is_editing: bool,
62    /// Listeners to notify on changes
63    listeners: Vec<ChangeListener>,
64    /// Undo stack - previous states to restore
65    undo_stack: VecDeque<TextFieldValue>,
66    /// Redo stack - states undone that can be redone
67    redo_stack: VecDeque<TextFieldValue>,
68    /// Desired column for up/down navigation (preserved between vertical moves)
69    desired_column: Cell<Option<usize>>,
70    /// Last edit timestamp for undo coalescing
71    last_edit_time: Cell<Option<web_time::Instant>>,
72    /// Snapshot before the current coalescing group started
73    /// Only pushed to undo_stack when coalescing breaks
74    pending_undo_snapshot: RefCell<Option<TextFieldValue>>,
75    /// Cached line start offsets for O(1) line lookups during rendering.
76    /// Invalidated on text change. Each entry is byte offset of line start.
77    /// e.g., for "ab\ncd" -> [0, 3] (line 0 starts at 0, line 1 starts at 3)
78    line_offsets_cache: RefCell<Option<Vec<usize>>>,
79}
80
81/// RAII guard for is_editing flag - ensures panic safety
82struct EditGuard<'a> {
83    inner: &'a RefCell<TextFieldStateInner>,
84}
85
86impl<'a> EditGuard<'a> {
87    fn new(inner: &'a RefCell<TextFieldStateInner>) -> Result<Self, ()> {
88        {
89            let borrowed = inner.borrow();
90            if borrowed.is_editing {
91                return Err(()); // Already editing
92            }
93        }
94        inner.borrow_mut().is_editing = true;
95        Ok(Self { inner })
96    }
97}
98
99impl Drop for EditGuard<'_> {
100    fn drop(&mut self) {
101        self.inner.borrow_mut().is_editing = false;
102    }
103}
104
105/// Observable state holder for text field content.
106///
107/// This is the primary API for managing text field state. All edits go through
108/// the [`edit`](Self::edit) method which provides a mutable buffer.
109///
110/// # Example
111///
112/// ```ignore
113/// use cranpose_foundation::text::TextFieldState;
114///
115/// let state = TextFieldState::new("Hello");
116///
117/// // Edit the text
118/// state.edit(|buffer| {
119///     buffer.place_cursor_at_end();
120///     buffer.insert(", World!");
121/// });
122///
123/// assert_eq!(state.text(), "Hello, World!");
124/// ```
125///
126/// # Thread Safety
127///
128/// `TextFieldState` uses `Rc<RefCell<...>>` internally and is not thread-safe.
129/// It should only be used from the main thread.
130#[derive(Clone)]
131pub struct TextFieldState {
132    /// Internal state for editing machinery.
133    /// Public for cross-crate pointer-based identity comparison (Hash).
134    pub inner: Rc<RefCell<TextFieldStateInner>>,
135
136    /// Value storage - the SINGLE source of truth for text field value.
137    /// Uses MutableState for reactive composition integration.
138    value: Rc<cranpose_core::OwnedMutableState<TextFieldValue>>,
139}
140
141impl std::fmt::Debug for TextFieldState {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        self.value.with(|v| {
144            f.debug_struct("TextFieldState")
145                .field("text", &v.text)
146                .field("selection", &v.selection)
147                .finish()
148        })
149    }
150}
151
152impl TextFieldState {
153    /// Creates a new text field state with the given initial text.
154    pub fn new(initial_text: impl Into<String>) -> Self {
155        let initial_value = TextFieldValue::new(initial_text);
156        Self {
157            inner: Rc::new(RefCell::new(TextFieldStateInner {
158                is_editing: false,
159                listeners: Vec::new(),
160                undo_stack: VecDeque::new(),
161                redo_stack: VecDeque::new(),
162                desired_column: Cell::new(None),
163                last_edit_time: Cell::new(None),
164                pending_undo_snapshot: RefCell::new(None),
165                line_offsets_cache: RefCell::new(None),
166            })),
167            value: Rc::new(cranpose_core::ownedMutableStateOf(initial_value)),
168        }
169    }
170
171    /// Creates a state with initial text and selection.
172    pub fn with_selection(initial_text: impl Into<String>, selection: TextRange) -> Self {
173        let initial_value = TextFieldValue::with_selection(initial_text, selection);
174        Self {
175            inner: 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                line_offsets_cache: RefCell::new(None),
184            })),
185            value: Rc::new(cranpose_core::ownedMutableStateOf(initial_value)),
186        }
187    }
188
189    /// Gets the desired column for up/down navigation.
190    pub fn desired_column(&self) -> Option<usize> {
191        self.inner.borrow().desired_column.get()
192    }
193
194    /// Sets the desired column for up/down navigation.
195    pub fn set_desired_column(&self, col: Option<usize>) {
196        self.inner.borrow().desired_column.set(col);
197    }
198
199    /// Returns the current text content.
200    /// Creates composition dependency when read during composition.
201    pub fn text(&self) -> String {
202        self.value.with(|v| v.text.clone())
203    }
204
205    /// Returns the current selection range.
206    pub fn selection(&self) -> TextRange {
207        self.value.with(|v| v.selection)
208    }
209
210    /// Returns the current composition (IME) range, if any.
211    pub fn composition(&self) -> Option<TextRange> {
212        self.value.with(|v| v.composition)
213    }
214
215    /// Returns cached line start offsets for efficient multiline operations.
216    ///
217    /// Each entry is the byte offset where a line starts. For example:
218    /// - "ab\ncd" -> [0, 3] (line 0 starts at 0, line 1 starts at 3)
219    /// - "" -> [0]
220    ///
221    /// The cache is lazily computed on first access and invalidated on text change.
222    /// This avoids O(n) string splitting on every frame during selection rendering.
223    pub fn line_offsets(&self) -> Vec<usize> {
224        let inner = self.inner.borrow();
225
226        // Check if cached
227        if let Some(ref offsets) = *inner.line_offsets_cache.borrow() {
228            return offsets.clone();
229        }
230
231        // Compute line offsets
232        let text = self.text();
233        let mut offsets = vec![0];
234        for (i, c) in text.char_indices() {
235            if c == '\n' {
236                // Next line starts at i + 1 (byte after newline)
237                // Note: '\n' is always 1 byte in UTF-8
238                offsets.push(i + 1);
239            }
240        }
241
242        // Cache and return
243        *inner.line_offsets_cache.borrow_mut() = Some(offsets.clone());
244        offsets
245    }
246
247    /// Invalidates the cached line offsets. Called internally on text change.
248    fn invalidate_line_cache(&self) {
249        self.inner.borrow().line_offsets_cache.borrow_mut().take();
250    }
251
252    /// Copies the selected text without modifying the clipboard.
253    /// Returns the selected text, or None if no selection.
254    pub fn copy_selection(&self) -> Option<String> {
255        self.value.with(|v| {
256            let selection = v.selection;
257            if selection.collapsed() {
258                return None;
259            }
260            let start = selection.min();
261            let end = selection.max();
262            Some(v.text[start..end].to_string())
263        })
264    }
265
266    /// Returns the current value snapshot.
267    /// Creates composition dependency when read during composition.
268    pub fn value(&self) -> TextFieldValue {
269        self.value.with(|v| v.clone())
270    }
271
272    /// Adds a listener that is called when the value changes.
273    ///
274    /// Returns the listener index for removal.
275    pub fn add_listener(&self, listener: impl Fn(&TextFieldValue) + 'static) -> usize {
276        let mut inner = self.inner.borrow_mut();
277        let index = inner.listeners.len();
278        inner.listeners.push(Box::new(listener));
279        index
280    }
281
282    /// Sets the selection directly without going through undo stack.
283    /// Use this for transient selection changes like during drag selection.
284    pub fn set_selection(&self, selection: TextRange) {
285        let new_value = self.value.with(|v| {
286            let len = v.text.len();
287            TextFieldValue {
288                text: v.text.clone(),
289                selection: selection.coerce_in(len),
290                composition: v.composition,
291            }
292        });
293        self.value.set(new_value);
294    }
295
296    /// Returns true if undo is available.
297    pub fn can_undo(&self) -> bool {
298        !self.inner.borrow().undo_stack.is_empty()
299    }
300
301    /// Returns true if redo is available.
302    pub fn can_redo(&self) -> bool {
303        !self.inner.borrow().redo_stack.is_empty()
304    }
305
306    /// Undoes the last edit.
307    /// Returns true if undo was performed.
308    pub fn undo(&self) -> bool {
309        // First, flush any pending coalescing snapshot so it becomes the undo target
310        self.flush_undo_group();
311
312        let mut inner = self.inner.borrow_mut();
313        if let Some(previous_state) = inner.undo_stack.pop_back() {
314            // Save current state to redo stack
315            let current = self.value.with(|v| v.clone());
316            inner.redo_stack.push_back(current);
317            // Clear coalescing state since we're undoing
318            inner.last_edit_time.set(None);
319            drop(inner);
320            // Update value via MutableState (triggers recomposition)
321            self.value.set(previous_state);
322            true
323        } else {
324            false
325        }
326    }
327
328    /// Redoes the last undone edit.
329    /// Returns true if redo was performed.
330    pub fn redo(&self) -> bool {
331        let mut inner = self.inner.borrow_mut();
332        if let Some(redo_state) = inner.redo_stack.pop_back() {
333            // Save current state to undo stack
334            let current = self.value.with(|v| v.clone());
335            inner.undo_stack.push_back(current);
336            drop(inner);
337            // Update value via MutableState (triggers recomposition)
338            self.value.set(redo_state);
339            true
340        } else {
341            false
342        }
343    }
344
345    /// Edits the text field content.
346    ///
347    /// The provided closure receives a mutable buffer that can be used to
348    /// modify the text and selection. After the closure returns, the changes
349    /// are committed and listeners are notified.
350    ///
351    /// # Undo Coalescing
352    ///
353    /// Consecutive character insertions within the coalescing timeout are grouped
354    /// into a single undo entry. The group breaks when:
355    /// - Timeout expires (1 second between edits)
356    /// - Whitespace or newline is typed
357    /// - Cursor position jumps (non-consecutive insert)
358    /// - A non-insert operation occurs (delete, paste multi-char, etc.)
359    ///
360    /// Returns `false` if another edit is already in progress.
361    pub fn edit<F>(&self, f: F) -> bool
362    where
363        F: FnOnce(&mut TextFieldBuffer),
364    {
365        let Ok(guard) = EditGuard::new(&self.inner) else {
366            return false;
367        };
368
369        // Create buffer from current value
370        let current = self.value();
371        let mut buffer = TextFieldBuffer::with_selection(&current.text, current.selection);
372        if let Some(comp) = current.composition {
373            buffer.set_composition(Some(comp));
374        }
375
376        // Execute the edit
377        f(&mut buffer);
378
379        // Build new value
380        let new_value = TextFieldValue {
381            text: buffer.text().to_string(),
382            selection: buffer.selection(),
383            composition: buffer.composition(),
384        };
385
386        // Only update and notify if changed
387        let changed = new_value != current;
388        let text_changed = new_value.text != current.text;
389
390        // Invalidate line cache if text changed (not just selection)
391        if text_changed {
392            self.invalidate_line_cache();
393        }
394
395        if changed {
396            let now = web_time::Instant::now();
397
398            // Determine if we should break the undo coalescing group
399            let should_break_group = {
400                let inner = self.inner.borrow();
401
402                // Check timeout
403                let timeout_expired = inner
404                    .last_edit_time
405                    .get()
406                    .map(|last| now.duration_since(last).as_millis() > UNDO_COALESCE_MS)
407                    .unwrap_or(true);
408
409                if timeout_expired {
410                    true
411                } else {
412                    // Check if this looks like a single character insert
413                    let text_delta = new_value.text.len() as i64 - current.text.len() as i64;
414                    let is_single_char_insert = text_delta == 1;
415
416                    // Check if it's whitespace/newline (break group on word boundaries)
417                    let ends_with_whitespace = new_value.text.ends_with(char::is_whitespace);
418
419                    // Check if cursor jumped (non-consecutive editing)
420                    let cursor_jumped = new_value.selection.start != current.selection.start + 1
421                        && new_value.selection.start != current.selection.end + 1;
422
423                    // Break if not a simple character insert, or if whitespace/newline, or cursor jumped
424                    !is_single_char_insert || ends_with_whitespace || cursor_jumped
425                }
426            };
427
428            {
429                let inner = self.inner.borrow();
430
431                if should_break_group {
432                    // Push pending snapshot (if any) to undo stack, then start new group
433                    let pending = inner.pending_undo_snapshot.take();
434                    drop(inner);
435
436                    let mut inner = self.inner.borrow_mut();
437                    if let Some(snapshot) = pending {
438                        if inner.undo_stack.len() >= UNDO_CAPACITY {
439                            inner.undo_stack.pop_front();
440                        }
441                        inner.undo_stack.push_back(snapshot);
442                    }
443                    // Clear redo stack on new edit
444                    inner.redo_stack.clear();
445                    // Start new coalescing group with current state as pending snapshot
446                    drop(inner);
447                    self.inner
448                        .borrow()
449                        .pending_undo_snapshot
450                        .replace(Some(current.clone()));
451                } else {
452                    // Continue coalescing - pending snapshot stays as-is
453                    // If no pending snapshot, start one
454                    if inner.pending_undo_snapshot.borrow().is_none() {
455                        inner.pending_undo_snapshot.replace(Some(current.clone()));
456                    }
457                    drop(inner);
458                    // Clear redo stack on new edit
459                    self.inner.borrow_mut().redo_stack.clear();
460                }
461
462                // Update last edit time
463                self.inner.borrow().last_edit_time.set(Some(now));
464            }
465
466            // Update value via MutableState (triggers recomposition)
467            self.value.set(new_value.clone());
468        }
469
470        // Explicitly drop guard to clear is_editing BEFORE notifying listeners
471        // This ensures listeners see clean state and can start new edits if needed
472        drop(guard);
473
474        // Notify listeners outside of borrow
475        if changed {
476            let listener_count = self.inner.borrow().listeners.len();
477            for i in 0..listener_count {
478                let inner = self.inner.borrow();
479                if i < inner.listeners.len() {
480                    (inner.listeners[i])(&new_value);
481                }
482            }
483        }
484        true
485    }
486
487    /// Flushes any pending undo snapshot to the undo stack.
488    /// Call this when a coalescing break is desired (e.g., focus lost).
489    pub fn flush_undo_group(&self) {
490        let inner = self.inner.borrow();
491        if let Some(snapshot) = inner.pending_undo_snapshot.take() {
492            drop(inner);
493            let mut inner = self.inner.borrow_mut();
494            if inner.undo_stack.len() >= UNDO_CAPACITY {
495                inner.undo_stack.pop_front();
496            }
497            inner.undo_stack.push_back(snapshot);
498        }
499    }
500
501    /// Sets the text and places cursor at end.
502    pub fn set_text(&self, text: impl Into<String>) -> bool {
503        let text = text.into();
504        self.edit(|buffer| {
505            buffer.clear();
506            buffer.insert(&text);
507        })
508    }
509
510    /// Sets the text and selects all.
511    pub fn set_text_and_select_all(&self, text: impl Into<String>) -> bool {
512        let text = text.into();
513        self.edit(|buffer| {
514            buffer.clear();
515            buffer.insert(&text);
516            buffer.select_all();
517        })
518    }
519}
520
521impl Default for TextFieldState {
522    fn default() -> Self {
523        Self::new("")
524    }
525}
526
527impl PartialEq for TextFieldState {
528    fn eq(&self, other: &Self) -> bool {
529        // Compare by Rc pointer identity - same state instance
530        Rc::ptr_eq(&self.inner, &other.inner)
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use cranpose_core::{DefaultScheduler, Runtime};
538    use std::sync::Arc;
539
540    /// Sets up a test runtime and keeps it alive for the duration of the test.
541    /// This is required because TextFieldState uses MutableState which requires
542    /// an active runtime context.
543    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
544        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
545        f()
546    }
547
548    #[test]
549    fn new_state_has_cursor_at_end() {
550        with_test_runtime(|| {
551            let state = TextFieldState::new("Hello");
552            assert_eq!(state.text(), "Hello");
553            assert_eq!(state.selection(), TextRange::cursor(5));
554        });
555    }
556
557    #[test]
558    fn edit_updates_text() {
559        with_test_runtime(|| {
560            let state = TextFieldState::new("Hello");
561            state.edit(|buffer| {
562                buffer.place_cursor_at_end();
563                buffer.insert(", World!");
564            });
565            assert_eq!(state.text(), "Hello, World!");
566        });
567    }
568
569    #[test]
570    fn edit_updates_selection() {
571        with_test_runtime(|| {
572            let state = TextFieldState::new("Hello");
573            state.edit(|buffer| {
574                buffer.select_all();
575            });
576            assert_eq!(state.selection(), TextRange::new(0, 5));
577        });
578    }
579
580    #[test]
581    fn set_text_replaces_content() {
582        with_test_runtime(|| {
583            let state = TextFieldState::new("Hello");
584            state.set_text("Goodbye");
585            assert_eq!(state.text(), "Goodbye");
586            assert_eq!(state.selection(), TextRange::cursor(7));
587        });
588    }
589
590    #[test]
591    fn nested_edit_is_rejected() {
592        with_test_runtime(|| {
593            use std::cell::Cell;
594            use std::rc::Rc;
595
596            let state = TextFieldState::new("Hello");
597            let state_clone = state.clone();
598            let nested_result = Rc::new(Cell::new(true));
599            let nested_result_for_edit = nested_result.clone();
600            let outer_result = state.edit(move |_buffer| {
601                nested_result_for_edit.set(state_clone.edit(|_| {}));
602            });
603            assert!(outer_result);
604            assert!(!nested_result.get());
605        });
606    }
607
608    #[test]
609    fn listener_is_called_on_change() {
610        with_test_runtime(|| {
611            use std::cell::Cell;
612            use std::rc::Rc;
613
614            let state = TextFieldState::new("Hello");
615            let called = Rc::new(Cell::new(false));
616            let called_clone = called.clone();
617
618            state.add_listener(move |_value| {
619                called_clone.set(true);
620            });
621
622            state.edit(|buffer| {
623                buffer.insert("!");
624            });
625
626            assert!(called.get());
627        });
628    }
629}