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    /// # Panics
361    ///
362    /// Panics if called while already editing (no concurrent or nested edits).
363    pub fn edit<F>(&self, f: F)
364    where
365        F: FnOnce(&mut TextFieldBuffer),
366    {
367        // RAII guard ensures is_editing is cleared even on panic
368        let _guard = EditGuard::new(&self.inner)
369            .expect("TextFieldState does not support concurrent or nested editing");
370
371        // Create buffer from current value
372        let current = self.value();
373        let mut buffer = TextFieldBuffer::with_selection(&current.text, current.selection);
374        if let Some(comp) = current.composition {
375            buffer.set_composition(Some(comp));
376        }
377
378        // Execute the edit
379        f(&mut buffer);
380
381        // Build new value
382        let new_value = TextFieldValue {
383            text: buffer.text().to_string(),
384            selection: buffer.selection(),
385            composition: buffer.composition(),
386        };
387
388        // Only update and notify if changed
389        let changed = new_value != current;
390        let text_changed = new_value.text != current.text;
391
392        // Invalidate line cache if text changed (not just selection)
393        if text_changed {
394            self.invalidate_line_cache();
395        }
396
397        if changed {
398            let now = web_time::Instant::now();
399
400            // Determine if we should break the undo coalescing group
401            let should_break_group = {
402                let inner = self.inner.borrow();
403
404                // Check timeout
405                let timeout_expired = inner
406                    .last_edit_time
407                    .get()
408                    .map(|last| now.duration_since(last).as_millis() > UNDO_COALESCE_MS)
409                    .unwrap_or(true);
410
411                if timeout_expired {
412                    true
413                } else {
414                    // Check if this looks like a single character insert
415                    let text_delta = new_value.text.len() as i64 - current.text.len() as i64;
416                    let is_single_char_insert = text_delta == 1;
417
418                    // Check if it's whitespace/newline (break group on word boundaries)
419                    let ends_with_whitespace = new_value.text.ends_with(char::is_whitespace);
420
421                    // Check if cursor jumped (non-consecutive editing)
422                    let cursor_jumped = new_value.selection.start != current.selection.start + 1
423                        && new_value.selection.start != current.selection.end + 1;
424
425                    // Break if not a simple character insert, or if whitespace/newline, or cursor jumped
426                    !is_single_char_insert || ends_with_whitespace || cursor_jumped
427                }
428            };
429
430            {
431                let inner = self.inner.borrow();
432
433                if should_break_group {
434                    // Push pending snapshot (if any) to undo stack, then start new group
435                    let pending = inner.pending_undo_snapshot.take();
436                    drop(inner);
437
438                    let mut inner = self.inner.borrow_mut();
439                    if let Some(snapshot) = pending {
440                        if inner.undo_stack.len() >= UNDO_CAPACITY {
441                            inner.undo_stack.pop_front();
442                        }
443                        inner.undo_stack.push_back(snapshot);
444                    }
445                    // Clear redo stack on new edit
446                    inner.redo_stack.clear();
447                    // Start new coalescing group with current state as pending snapshot
448                    drop(inner);
449                    self.inner
450                        .borrow()
451                        .pending_undo_snapshot
452                        .replace(Some(current.clone()));
453                } else {
454                    // Continue coalescing - pending snapshot stays as-is
455                    // If no pending snapshot, start one
456                    if inner.pending_undo_snapshot.borrow().is_none() {
457                        inner.pending_undo_snapshot.replace(Some(current.clone()));
458                    }
459                    drop(inner);
460                    // Clear redo stack on new edit
461                    self.inner.borrow_mut().redo_stack.clear();
462                }
463
464                // Update last edit time
465                self.inner.borrow().last_edit_time.set(Some(now));
466            }
467
468            // Update value via MutableState (triggers recomposition)
469            self.value.set(new_value.clone());
470        }
471
472        // Explicitly drop guard to clear is_editing BEFORE notifying listeners
473        // This ensures listeners see clean state and can start new edits if needed
474        drop(_guard);
475
476        // Notify listeners outside of borrow
477        if changed {
478            let listener_count = self.inner.borrow().listeners.len();
479            for i in 0..listener_count {
480                let inner = self.inner.borrow();
481                if i < inner.listeners.len() {
482                    (inner.listeners[i])(&new_value);
483                }
484            }
485        }
486    }
487
488    /// Flushes any pending undo snapshot to the undo stack.
489    /// Call this when a coalescing break is desired (e.g., focus lost).
490    pub fn flush_undo_group(&self) {
491        let inner = self.inner.borrow();
492        if let Some(snapshot) = inner.pending_undo_snapshot.take() {
493            drop(inner);
494            let mut inner = self.inner.borrow_mut();
495            if inner.undo_stack.len() >= UNDO_CAPACITY {
496                inner.undo_stack.pop_front();
497            }
498            inner.undo_stack.push_back(snapshot);
499        }
500    }
501
502    /// Sets the text and places cursor at end.
503    pub fn set_text(&self, text: impl Into<String>) {
504        let text = text.into();
505        self.edit(|buffer| {
506            buffer.clear();
507            buffer.insert(&text);
508        });
509    }
510
511    /// Sets the text and selects all.
512    pub fn set_text_and_select_all(&self, text: impl Into<String>) {
513        let text = text.into();
514        self.edit(|buffer| {
515            buffer.clear();
516            buffer.insert(&text);
517            buffer.select_all();
518        });
519    }
520}
521
522impl Default for TextFieldState {
523    fn default() -> Self {
524        Self::new("")
525    }
526}
527
528impl PartialEq for TextFieldState {
529    fn eq(&self, other: &Self) -> bool {
530        // Compare by Rc pointer identity - same state instance
531        Rc::ptr_eq(&self.inner, &other.inner)
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use cranpose_core::{DefaultScheduler, Runtime};
539    use std::sync::Arc;
540
541    /// Sets up a test runtime and keeps it alive for the duration of the test.
542    /// This is required because TextFieldState uses MutableState which requires
543    /// an active runtime context.
544    fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
545        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
546        f()
547    }
548
549    #[test]
550    fn new_state_has_cursor_at_end() {
551        with_test_runtime(|| {
552            let state = TextFieldState::new("Hello");
553            assert_eq!(state.text(), "Hello");
554            assert_eq!(state.selection(), TextRange::cursor(5));
555        });
556    }
557
558    #[test]
559    fn edit_updates_text() {
560        with_test_runtime(|| {
561            let state = TextFieldState::new("Hello");
562            state.edit(|buffer| {
563                buffer.place_cursor_at_end();
564                buffer.insert(", World!");
565            });
566            assert_eq!(state.text(), "Hello, World!");
567        });
568    }
569
570    #[test]
571    fn edit_updates_selection() {
572        with_test_runtime(|| {
573            let state = TextFieldState::new("Hello");
574            state.edit(|buffer| {
575                buffer.select_all();
576            });
577            assert_eq!(state.selection(), TextRange::new(0, 5));
578        });
579    }
580
581    #[test]
582    fn set_text_replaces_content() {
583        with_test_runtime(|| {
584            let state = TextFieldState::new("Hello");
585            state.set_text("Goodbye");
586            assert_eq!(state.text(), "Goodbye");
587            assert_eq!(state.selection(), TextRange::cursor(7));
588        });
589    }
590
591    #[test]
592    #[should_panic(expected = "concurrent or nested editing")]
593    fn nested_edit_panics() {
594        with_test_runtime(|| {
595            let state = TextFieldState::new("Hello");
596            let state_clone = state.clone();
597            state.edit(move |_buffer| {
598                state_clone.edit(|_| {}); // This should panic
599            });
600        });
601    }
602
603    #[test]
604    fn listener_is_called_on_change() {
605        with_test_runtime(|| {
606            use std::cell::Cell;
607            use std::rc::Rc;
608
609            let state = TextFieldState::new("Hello");
610            let called = Rc::new(Cell::new(false));
611            let called_clone = called.clone();
612
613            state.add_listener(move |_value| {
614                called_clone.set(true);
615            });
616
617            state.edit(|buffer| {
618                buffer.insert("!");
619            });
620
621            assert!(called.get());
622        });
623    }
624}