Skip to main content

cranpose_foundation/text/
state.rs

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