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