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