Skip to main content

ferroui_controls/
text_box_history.rs

1//! Rich state follows the TextBox's existing text undo snapshots. This module
2//! adds no second history and deliberately keeps text-only snapshot equality.
3
4use super::TextBox;
5use crate::presenters::TextStyleOverrides;
6use ferroui_base::media::text_formatting::TextParagraphStyles;
7use std::any::Any;
8use std::fmt;
9use std::rc::Rc;
10
11/// A native text undo checkpoint. Caret, styles and the application's immutable
12/// payload travel with the text; they do not create separate undo steps.
13#[derive(Clone)]
14pub(crate) struct UndoRedoState {
15    pub(super) text: Option<String>,
16    caret_position: i32,
17    styles: Option<TextStyleOverrides>,
18    paragraph_styles: Option<TextParagraphStyles>,
19    application_state: Option<Rc<dyn Any>>,
20}
21
22impl UndoRedoState {
23    pub(super) fn new(text: Option<String>, caret_position: i32) -> Self {
24        Self {
25            text,
26            caret_position,
27            styles: None,
28            paragraph_styles: None,
29            application_state: None,
30        }
31    }
32}
33
34impl fmt::Debug for UndoRedoState {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("UndoRedoState")
38            .field("text", &self.text)
39            .field("caret_position", &self.caret_position)
40            .field(
41                "style_count",
42                &self.styles.as_ref().map_or(0, |styles| styles.len()),
43            )
44            .field(
45                "paragraph_count",
46                &self
47                    .paragraph_styles
48                    .as_ref()
49                    .map_or(0, |styles| styles.len()),
50            )
51            .field("has_application_state", &self.application_state.is_some())
52            .finish()
53    }
54}
55
56impl PartialEq for UndoRedoState {
57    fn eq(&self, other: &Self) -> bool {
58        self.text == other.text
59    }
60}
61
62impl TextBox {
63    /// The application's immutable rich-text snapshot for the current text.
64    ///
65    /// Native undo and redo restore this value, together with character and
66    /// paragraph styles, before the synchronous `TextChanging` notification.
67    /// An editor can downcast it and restore its document ranges exactly rather
68    /// than guessing the deleted styling from the old and new strings. The
69    /// application should verify that the snapshot describes the current Text.
70    pub fn text_undo_state(&self) -> Option<Rc<dyn Any>> {
71        self.text_undo_state.borrow().clone()
72    }
73
74    /// Associate an immutable application snapshot with the current text.
75    ///
76    /// Call this after updating rich styles in `TextChanging`, including after
77    /// an external Text replacement. If the native undo helper has already
78    /// captured this exact text, its current final snapshot is updated in place.
79    /// This neither adds an undo step nor discards redo. Snapshots share the
80    /// native undo limit and coalescing rules; callers must not mutate a payload
81    /// after passing it here.
82    pub fn set_text_undo_state(&self, value: Option<Rc<dyn Any>>) {
83        // Do not hold a RefCell borrow while dropping an application object.
84        let previous = self.text_undo_state.replace(value);
85        drop(previous);
86        if !self.is_undo_enabled() {
87            return;
88        }
89        // update_last_state_with alone always writes the deque's last entry.
90        // It must not overwrite a future redo checkpoint when the cursor is at
91        // an earlier state with coincidentally equal text.
92        if let Some(last) = self.undo_redo_helper.try_get_last_state() {
93            let current = self.capture_text_history_state();
94            if last.text == current.text {
95                self.undo_redo_helper.update_last_state_with(current);
96            }
97        }
98    }
99
100    /// Whether native undo or redo is currently restoring a text snapshot.
101    ///
102    /// This is intended for synchronous `TextChanging` handlers. The later,
103    /// asynchronous `TextChanged` notification runs after restoration has ended.
104    pub fn is_restoring_text_history(&self) -> bool {
105        self.is_undoing_redoing.get()
106    }
107
108    pub(super) fn capture_text_history_state(&self) -> UndoRedoState {
109        UndoRedoState {
110            text: self.text(),
111            caret_position: self.caret_index(),
112            styles: self.text_style_overrides(),
113            paragraph_styles: self.text_paragraph_styles(),
114            application_state: self.text_undo_state(),
115        }
116    }
117
118    pub(super) fn restore_text_history_state(&self, state: UndoRedoState) {
119        let generation = self.text_change_generation.get();
120        let _change = self.im_client.begin_change();
121
122        // Publish the payload directly, not through the public setter: Text
123        // still has its outgoing value and must not update that checkpoint.
124        let previous = self.text_undo_state.replace(state.application_state);
125        drop(previous);
126        if self.text_change_generation.get() != generation {
127            return;
128        }
129        self.set_current_value(Self::text_style_overrides_property(), state.styles);
130        if self.text_change_generation.get() != generation {
131            return;
132        }
133        self.set_current_value(
134            Self::text_paragraph_styles_property(),
135            state.paragraph_styles,
136        );
137        if self.text_change_generation.get() != generation {
138            return;
139        }
140
141        // Style-property callbacks can replace Text before this assignment;
142        // TextChanging and subclass coercion can replace it during assignment.
143        // Never apply a stale caret/selection over a newer handler-owned edit,
144        // including a change away from, and then back to, the requested text.
145        self.set_current_value(Self::text_property(), state.text.clone());
146        if self.text_change_generation.get().wrapping_sub(generation) > 1
147            || self.text() != state.text
148        {
149            return;
150        }
151        let generation = self.text_change_generation.get();
152        self.set_current_value(Self::caret_index_property(), state.caret_position);
153        if self.text_change_generation.get() != generation {
154            return;
155        }
156        let caret = self.caret_index();
157        self.set_current_value(Self::selection_start_property(), caret);
158        if self.text_change_generation.get() != generation {
159            return;
160        }
161        self.set_current_value(Self::selection_end_property(), caret);
162    }
163}
164
165#[cfg(test)]
166#[path = "text_box_history_tests.rs"]
167mod tests;