Skip to main content

agg_gui/widgets/
text_field_core.rs

1//! Internal helpers for `TextField`: UTF-8 navigation, word boundaries,
2//! hit-test geometry, shared edit state, and the undo command type.
3
4use std::cell::RefCell;
5use std::rc::Rc;
6
7use crate::text::measure_advance;
8use crate::text::Font;
9use crate::undo::UndoRedoCommand;
10
11// ---------------------------------------------------------------------------
12// UTF-8 boundary helpers
13// ---------------------------------------------------------------------------
14
15pub fn prev_char_boundary(s: &str, byte_pos: usize) -> usize {
16    let mut pos = byte_pos;
17    loop {
18        if pos == 0 {
19            return 0;
20        }
21        pos -= 1;
22        if s.is_char_boundary(pos) {
23            return pos;
24        }
25    }
26}
27
28pub fn next_char_boundary(s: &str, byte_pos: usize) -> usize {
29    let mut pos = byte_pos + 1;
30    while pos <= s.len() {
31        if s.is_char_boundary(pos) {
32            return pos;
33        }
34        pos += 1;
35    }
36    s.len()
37}
38
39// ---------------------------------------------------------------------------
40// Word-boundary helpers
41// ---------------------------------------------------------------------------
42
43fn is_word_char(c: char) -> bool {
44    c.is_alphanumeric() || c == '_'
45}
46
47/// Ctrl+Right: advance to end of next token (skip whitespace then non-whitespace).
48pub fn next_word_boundary(s: &str, pos: usize) -> usize {
49    let mut chars = s[pos..].char_indices().peekable();
50    let mut advanced = 0usize;
51    // skip leading whitespace
52    while let Some(&(i, c)) = chars.peek() {
53        if !c.is_whitespace() {
54            break;
55        }
56        advanced = i + c.len_utf8();
57        chars.next();
58    }
59    // skip non-whitespace
60    while let Some(&(i, c)) = chars.peek() {
61        if c.is_whitespace() {
62            break;
63        }
64        advanced = i + c.len_utf8();
65        chars.next();
66    }
67    pos + advanced
68}
69
70/// Ctrl+Left: retreat to start of previous token.
71pub fn prev_word_boundary(s: &str, pos: usize) -> usize {
72    if pos == 0 {
73        return 0;
74    }
75    let chars: Vec<(usize, char)> = s[..pos].char_indices().collect();
76    let mut i = chars.len();
77    while i > 0 && chars[i - 1].1.is_whitespace() {
78        i -= 1;
79    }
80    while i > 0 && !chars[i - 1].1.is_whitespace() {
81        i -= 1;
82    }
83    if i < chars.len() {
84        chars[i].0
85    } else {
86        0
87    }
88}
89
90/// Returns `[start, end)` byte range of the word under `byte_pos`
91/// (used for double-click word selection).
92pub fn word_range_at(s: &str, byte_pos: usize) -> (usize, usize) {
93    let anchor_class = is_word_char(s[byte_pos..].chars().next().unwrap_or(' '));
94    // walk back
95    let start = {
96        let mut p = byte_pos;
97        while p > 0 {
98            let prev = prev_char_boundary(s, p);
99            let c = s[prev..p].chars().next().unwrap_or(' ');
100            if is_word_char(c) != anchor_class {
101                break;
102            }
103            p = prev;
104        }
105        p
106    };
107    // walk forward
108    let end = {
109        let mut p = byte_pos;
110        for (_, c) in s[byte_pos..].char_indices() {
111            if is_word_char(c) != anchor_class {
112                break;
113            }
114            p += c.len_utf8();
115        }
116        p
117    };
118    (start, end)
119}
120
121/// Returns the `[start, end)` byte range of the *logical* line (paragraph)
122/// containing `byte_pos` — everything between the surrounding `\n`s, excluding
123/// the newlines themselves. Used for triple-click line selection in the
124/// multiline editors. "Logical" means the source paragraph, not a soft-wrapped
125/// visual line, so a triple-click grabs the whole paragraph even when it wraps.
126pub fn paragraph_range_at(s: &str, byte_pos: usize) -> (usize, usize) {
127    let pos = byte_pos.min(s.len());
128    let start = s[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
129    let end = s[pos..].find('\n').map(|i| pos + i).unwrap_or(s.len());
130    (start, end)
131}
132
133// ---------------------------------------------------------------------------
134// X-coordinate ↔ byte-offset
135// ---------------------------------------------------------------------------
136
137/// Byte offset of the character boundary closest to `target_x` in rendered text.
138pub fn byte_at_x(font: &Font, text: &str, font_size: f64, target_x: f64) -> usize {
139    if target_x <= 0.0 {
140        return 0;
141    }
142    let mut prev_x = 0.0f64;
143    let mut prev_pos = 0usize;
144    for (i, c) in text.char_indices() {
145        let x = measure_advance(font, &text[..i], font_size);
146        let mid = (prev_x + x) * 0.5;
147        if target_x < mid {
148            return prev_pos;
149        }
150        prev_x = x;
151        prev_pos = i;
152        let _ = c;
153    }
154    let total = measure_advance(font, text, font_size);
155    let mid = (prev_x + total) * 0.5;
156    if target_x < mid {
157        prev_pos
158    } else {
159        text.len()
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Shared edit state
165// ---------------------------------------------------------------------------
166
167/// The mutable editing state shared between `TextField` and its undo commands.
168///
169/// `epoch` is a monotonically-increasing text-content revision. It lets a
170/// widget that caches something derived from `text` (e.g. `TextArea`'s wrapped
171/// visual lines) detect when the text was mutated *through a different owner of
172/// the same shared handle* — the case where the widget's own internal
173/// invalidation never ran. Bump it via [`note_text_change`](Self::note_text_change)
174/// whenever `text` is modified through a shared reference; cursor/anchor-only
175/// moves must NOT bump it (they don't affect wrapping).
176#[derive(Clone, Default)]
177pub struct TextEditState {
178    pub text: String,
179    pub cursor: usize,
180    pub anchor: usize,
181    pub epoch: u64,
182}
183
184impl TextEditState {
185    /// Record that `text` changed: advances the content [`epoch`](Self::epoch).
186    #[inline]
187    pub fn note_text_change(&mut self) {
188        self.epoch = self.epoch.wrapping_add(1);
189    }
190
191    /// Sorted `[start, end)` byte range of the current selection, or `None`
192    /// when the selection is empty (cursor == anchor).
193    #[inline]
194    pub fn selection_range(&self) -> Option<(usize, usize)> {
195        let lo = self.cursor.min(self.anchor);
196        let hi = self.cursor.max(self.anchor);
197        if hi > lo {
198            Some((lo, hi))
199        } else {
200            None
201        }
202    }
203}
204
205// ---------------------------------------------------------------------------
206// Undo command for text edits
207// ---------------------------------------------------------------------------
208
209/// Stores before/after snapshots of `TextEditState` and a shared reference
210/// to the live state so that undo/redo can restore it.
211pub struct TextEditCommand {
212    pub name: &'static str,
213    pub before: TextEditState,
214    pub after: TextEditState,
215    pub target: Rc<RefCell<TextEditState>>,
216}
217
218impl UndoRedoCommand for TextEditCommand {
219    fn name(&self) -> &str {
220        self.name
221    }
222    fn do_it(&mut self) {
223        *self.target.borrow_mut() = self.after.clone();
224    }
225    fn undo_it(&mut self) {
226        *self.target.borrow_mut() = self.before.clone();
227    }
228    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
229        self
230    }
231}
232
233#[cfg(test)]
234mod word_line_tests {
235    use super::{paragraph_range_at, word_range_at};
236
237    #[test]
238    fn word_range_selects_alnum_run() {
239        let s = "hello world";
240        // Click inside "hello".
241        assert_eq!(word_range_at(s, 2), (0, 5));
242        // Click inside "world".
243        assert_eq!(word_range_at(s, 8), (6, 11));
244    }
245
246    #[test]
247    fn word_range_stops_at_punctuation() {
248        let s = "foo.bar";
249        // "foo" and "bar" are separate words; the '.' is its own run.
250        assert_eq!(word_range_at(s, 1), (0, 3));
251        assert_eq!(word_range_at(s, 5), (4, 7));
252        // Clicking on the punctuation selects just the punctuation run.
253        assert_eq!(word_range_at(s, 3), (3, 4));
254    }
255
256    #[test]
257    fn word_range_at_word_edge() {
258        let s = "cat dog";
259        // Right at the boundary between "cat" and the space: the char at the
260        // click position is the space, so the whitespace run is selected.
261        assert_eq!(word_range_at(s, 3), (3, 4));
262        // One byte earlier is still inside "cat".
263        assert_eq!(word_range_at(s, 2), (0, 3));
264    }
265
266    #[test]
267    fn word_range_includes_underscore() {
268        let s = "snake_case here";
269        assert_eq!(word_range_at(s, 3), (0, 10));
270    }
271
272    #[test]
273    fn paragraph_range_single_line() {
274        let s = "just one line";
275        assert_eq!(paragraph_range_at(s, 4), (0, s.len()));
276    }
277
278    #[test]
279    fn paragraph_range_middle_line() {
280        let s = "first\nsecond\nthird";
281        // Offset 8 is inside "second".
282        assert_eq!(paragraph_range_at(s, 8), (6, 12));
283    }
284
285    #[test]
286    fn paragraph_range_last_line_no_trailing_newline() {
287        let s = "a\nbb\nccc";
288        assert_eq!(paragraph_range_at(s, 7), (5, 8));
289    }
290
291    #[test]
292    fn paragraph_range_on_blank_line() {
293        let s = "a\n\nb";
294        // Offset 2 is the empty line between the two newlines.
295        assert_eq!(paragraph_range_at(s, 2), (2, 2));
296    }
297}