repose_ui/
textfield.rs

1use repose_core::*;
2use std::ops::Range;
3use std::rc::Rc;
4use std::time::Duration;
5use std::{cell::RefCell, time::Instant};
6
7use ab_glyph::{Font, FontArc, PxScale, ScaleFont};
8use fontdb::Database;
9use std::sync::OnceLock;
10
11pub const TF_FONT_PX: f32 = 16.0;
12pub const TF_PADDING_X: f32 = 8.0;
13
14use unicode_segmentation::UnicodeSegmentation;
15
16pub struct TextMetrics {
17    /// positions[i] = advance up to the i-th grapheme (len == graphemes + 1)
18    pub positions: Vec<f32>,
19    /// byte_offsets[i] = byte index of the i-th grapheme (last == text.len())
20    pub byte_offsets: Vec<usize>,
21}
22
23pub fn measure_text(text: &str, px: u32) -> TextMetrics {
24    let m = repose_text::metrics_for_textfield(text, px as f32);
25    TextMetrics {
26        positions: m.positions,
27        byte_offsets: m.byte_offsets,
28    }
29}
30
31pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
32    // Now returns grapheme index for a byte position
33    match m.byte_offsets.binary_search(&byte) {
34        Ok(i) | Err(i) => i,
35    }
36}
37
38pub fn index_for_x_bytes(text: &str, px: u32, x: f32) -> usize {
39    let m = measure_text(text, px);
40    // nearest grapheme boundary -> byte index
41    let mut best_i = 0usize;
42    let mut best_d = f32::INFINITY;
43    for i in 0..m.positions.len() {
44        let d = (m.positions[i] - x).abs();
45        if d < best_d {
46            best_d = d;
47            best_i = i;
48        }
49    }
50    m.byte_offsets[best_i]
51}
52
53/// find prev/next grapheme boundaries around a byte index
54fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
55    let mut last = 0usize;
56    for (i, _) in text.grapheme_indices(true) {
57        if i >= byte {
58            break;
59        }
60        last = i;
61    }
62    last
63}
64
65fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
66    for (i, _) in text.grapheme_indices(true) {
67        if i > byte {
68            return i;
69        }
70    }
71    text.len()
72}
73
74// Returns cumulative X positions per codepoint boundary (len+1 entries; pos[0] = 0, pos[i] = advance up to char i)
75pub fn positions_for(text: &str, px: u32) -> Vec<f32> {
76    repose_text::metrics_for_textfield(text, px as f32).positions
77}
78
79// Clamp to [0..=len], nearest insertion point for a given x (content coords, before scroll)
80pub fn index_for_x(text: &str, px: u32, x: f32) -> usize {
81    let m = repose_text::metrics_for_textfield(text, px as f32);
82    // nearest boundary
83    let mut best = 0usize;
84    let mut dmin = f32::INFINITY;
85    for (i, &xx) in m.positions.iter().enumerate() {
86        let d = (xx - x).abs();
87        if d < dmin {
88            dmin = d;
89            best = i;
90        }
91    }
92    best
93}
94
95#[derive(Clone, Debug)]
96pub struct TextFieldState {
97    pub text: String,
98    pub selection: Range<usize>,
99    pub composition: Option<Range<usize>>, // IME composition range
100    pub scroll_offset: f32,
101    pub drag_anchor: Option<usize>, // caret index where drag began
102    pub blink_start: Instant,       // caret's
103    pub inner_width: f32,
104}
105
106impl TextFieldState {
107    pub fn new() -> Self {
108        Self {
109            text: String::new(),
110            selection: 0..0,
111            composition: None,
112            scroll_offset: 0.0,
113            drag_anchor: None,
114            blink_start: Instant::now(),
115            inner_width: 0.0,
116        }
117    }
118
119    pub fn insert_text(&mut self, text: &str) {
120        let start = self.selection.start.min(self.text.len());
121        let end = self.selection.end.min(self.text.len());
122
123        self.text.replace_range(start..end, text);
124        let new_pos = start + text.len();
125        self.selection = new_pos..new_pos;
126        self.reset_caret_blink();
127    }
128
129    pub fn delete_backward(&mut self) {
130        if self.selection.start == self.selection.end {
131            let pos = self.selection.start.min(self.text.len());
132            if pos > 0 {
133                let prev = prev_grapheme_boundary(&self.text, pos);
134                self.text.replace_range(prev..pos, "");
135                self.selection = prev..prev;
136            }
137        } else {
138            self.insert_text("");
139        }
140        self.reset_caret_blink();
141    }
142
143    pub fn delete_forward(&mut self) {
144        if self.selection.start == self.selection.end {
145            let pos = self.selection.start.min(self.text.len());
146            if pos < self.text.len() {
147                let next = next_grapheme_boundary(&self.text, pos);
148                self.text.replace_range(pos..next, "");
149            }
150        } else {
151            self.insert_text("");
152        }
153        self.reset_caret_blink();
154    }
155
156    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
157        let mut pos = self.selection.end.min(self.text.len());
158        if delta < 0 {
159            for _ in 0..delta.unsigned_abs() {
160                pos = prev_grapheme_boundary(&self.text, pos);
161            }
162        } else if delta > 0 {
163            for _ in 0..(delta as usize) {
164                pos = next_grapheme_boundary(&self.text, pos);
165            }
166        }
167        if extend_selection {
168            self.selection.end = pos;
169        } else {
170            self.selection = pos..pos;
171        }
172        self.reset_caret_blink();
173    }
174
175    pub fn selected_text(&self) -> String {
176        if self.selection.start == self.selection.end {
177            String::new()
178        } else {
179            self.text[self.selection.clone()].to_string()
180        }
181    }
182
183    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
184        if text.is_empty() {
185            if let Some(range) = self.composition.take() {
186                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
187                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
188                if s <= e {
189                    self.text.replace_range(s..e, "");
190                    self.selection = s..s;
191                }
192            }
193            self.reset_caret_blink();
194            return;
195        }
196
197        let anchor_start;
198        if let Some(r) = self.composition.take() {
199            // Clamp to current text and char boundaries
200            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
201            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
202            if e < s {
203                std::mem::swap(&mut s, &mut e);
204            }
205            self.text.replace_range(s..e, &text);
206            anchor_start = s;
207        } else {
208            // Insert at caret (snap to boundary)
209            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
210            self.text.insert_str(pos, &text);
211            anchor_start = pos;
212        }
213
214        // Update composition range to the new inserted/replaced span
215        self.composition = Some(anchor_start..(anchor_start + text.len()));
216
217        // Map IME cursor (char indices in `text`) to byte offsets relative to anchor_start
218        if let Some((c0, c1)) = cursor {
219            let b0 = char_to_byte(&text, c0);
220            let b1 = char_to_byte(&text, c1);
221            self.selection = (anchor_start + b0)..(anchor_start + b1);
222        } else {
223            let end = anchor_start + text.len();
224            self.selection = end..end;
225        }
226
227        self.reset_caret_blink();
228    }
229
230    pub fn commit_composition(&mut self, text: String) {
231        if let Some(r) = self.composition.take() {
232            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
233            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
234            self.text.replace_range(s..e, &text);
235            let new_pos = s + text.len();
236            self.selection = new_pos..new_pos;
237        } else {
238            // No active composition: insert at caret
239            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
240            self.text.insert_str(pos, &text);
241            let new_pos = pos + text.len();
242            self.selection = new_pos..new_pos;
243        }
244        self.reset_caret_blink();
245    }
246
247    pub fn cancel_composition(&mut self) {
248        if let Some(r) = self.composition.take() {
249            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
250            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
251            if s <= e {
252                self.text.replace_range(s..e, "");
253                self.selection = s..s;
254            }
255        }
256        self.reset_caret_blink();
257    }
258
259    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
260        if self.selection.start != self.selection.end {
261            let start = self.selection.start.min(self.text.len());
262            let end = self.selection.end.min(self.text.len());
263            self.text.replace_range(start..end, "");
264            self.selection = start..start;
265            self.reset_caret_blink();
266            return;
267        }
268
269        let caret = self.selection.end.min(self.text.len());
270        let start_raw = caret.saturating_sub(before_bytes);
271        let end_raw = (caret + after_bytes).min(self.text.len());
272        // Snap to nearest safe boundaries
273        let start = prev_grapheme_boundary(&self.text, start_raw);
274        let end = next_grapheme_boundary(&self.text, end_raw);
275        if start < end {
276            self.text.replace_range(start..end, "");
277            self.selection = start..start;
278        }
279        self.reset_caret_blink();
280    }
281
282    // Begin a selection on press; if extend==true, keep existing anchor; else set new anchor
283    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
284        let idx = idx_byte.min(self.text.len());
285        if extend {
286            let anchor = self.selection.start;
287            self.selection = anchor.min(idx)..anchor.max(idx);
288            self.drag_anchor = Some(anchor);
289        } else {
290            self.selection = idx..idx;
291            self.drag_anchor = Some(idx);
292        }
293        self.reset_caret_blink();
294    }
295
296    pub fn drag_to(&mut self, idx_byte: usize) {
297        if let Some(anchor) = self.drag_anchor {
298            let i = idx_byte.min(self.text.len());
299            self.selection = anchor.min(i)..anchor.max(i);
300        }
301        self.reset_caret_blink();
302    }
303    pub fn end_drag(&mut self) {
304        self.drag_anchor = None;
305    }
306
307    pub fn caret_index(&self) -> usize {
308        self.selection.end
309    }
310
311    // Keep caret visible inside inner content width
312    pub fn ensure_caret_visible(&mut self, caret_x: f32, inner_width: f32) {
313        // small 2px inset
314        let inset = 2.0;
315        let left = self.scroll_offset + inset;
316        let right = self.scroll_offset + inner_width - inset;
317        if caret_x < left {
318            self.scroll_offset = (caret_x - inset).max(0.0);
319        } else if caret_x > right {
320            self.scroll_offset = (caret_x - inner_width + inset).max(0.0);
321        }
322    }
323
324    pub fn reset_caret_blink(&mut self) {
325        self.blink_start = Instant::now();
326    }
327    pub fn caret_visible(&self) -> bool {
328        const PERIOD: Duration = Duration::from_millis(500);
329        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis() as u128) % 2 == 0
330    }
331
332    pub fn set_inner_width(&mut self, w: f32) {
333        self.inner_width = w.max(0.0);
334    }
335}
336
337// Platform-managed state: no Rc in builder, hint only.
338pub fn TextField(
339    hint: impl Into<String>,
340    modifier: repose_core::Modifier,
341    on_change: Option<impl Fn(String) + 'static>,
342    on_submit: Option<impl Fn(String) + 'static>,
343) -> repose_core::View {
344    repose_core::View::new(
345        0,
346        repose_core::ViewKind::TextField {
347            state_key: 0,
348            hint: hint.into(),
349            on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
350            on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
351        },
352    )
353    .modifier(modifier)
354    .semantics(repose_core::Semantics {
355        role: repose_core::Role::TextField,
356        label: None,
357        focused: false,
358        enabled: true,
359    })
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_textfield_insert() {
368        let mut state = TextFieldState::new();
369        state.insert_text("Hello");
370        assert_eq!(state.text, "Hello");
371        assert_eq!(state.selection, 5..5);
372    }
373
374    #[test]
375    fn test_textfield_delete_backward() {
376        let mut state = TextFieldState::new();
377        state.insert_text("Hello");
378        state.delete_backward();
379        assert_eq!(state.text, "Hell");
380        assert_eq!(state.selection, 4..4);
381    }
382
383    #[test]
384    fn test_textfield_selection() {
385        let mut state = TextFieldState::new();
386        state.insert_text("Hello World");
387        state.selection = 0..5; // Select "Hello"
388        state.insert_text("Hi");
389        assert_eq!(state.text, "Hi World");
390        assert_eq!(state.selection, 2..2);
391    }
392
393    #[test]
394    fn test_textfield_ime_composition() {
395        let mut state = TextFieldState::new();
396        state.insert_text("Test ");
397        state.set_composition("日本".to_string(), Some((0, 2)));
398        assert_eq!(state.text, "Test 日本");
399        assert!(state.composition.is_some());
400
401        state.commit_composition("日本語".to_string());
402        assert_eq!(state.text, "Test 日本語");
403        assert!(state.composition.is_none());
404    }
405
406    #[test]
407    fn test_textfield_cursor_movement() {
408        let mut state = TextFieldState::new();
409        state.insert_text("Hello");
410        state.move_cursor(-2, false);
411        assert_eq!(state.selection, 3..3);
412
413        state.move_cursor(1, false);
414        assert_eq!(state.selection, 4..4);
415    }
416
417    #[test]
418    fn test_delete_surrounding() {
419        let mut state = TextFieldState::new();
420        state.insert_text("Hello");
421        // caret at 5
422        state.delete_surrounding(2, 1); // delete "lo"
423        assert_eq!(state.text, "Hel");
424        assert_eq!(state.selection, 3..3);
425    }
426
427    #[test]
428    fn test_grapheme_delete_and_move() {
429        // "👍🏽" is a grapheme cluster (thumbs up + skin tone)
430        let mut st = TextFieldState::new();
431        st.insert_text("A👍🏽B");
432        assert_eq!(st.text, "A👍🏽B");
433        // Move left over 'B'
434        st.move_cursor(-1, false);
435        assert_eq!(st.selection.end, "A👍🏽".len());
436        st.delete_backward();
437        assert_eq!(st.text, "AB");
438        assert_eq!(st.selection, "A".len().."A".len());
439    }
440
441    #[test]
442    fn test_index_for_x_bytes_grapheme() {
443        // Ensure we return boundaries consistent with graphemes
444        let t = "A👍🏽B";
445        let px = 16u32;
446        let m = measure_text(t, px);
447        // All byte_offsets must be grapheme boundaries
448        for i in 0..m.byte_offsets.len() - 1 {
449            let b = m.byte_offsets[i];
450            // slicing at boundary must be OK
451            let _ = &t[..b];
452        }
453    }
454}
455
456fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
457    if i >= s.len() {
458        return s.len();
459    }
460    if s.is_char_boundary(i) {
461        return i;
462    }
463    // walk back to previous valid boundary
464    let mut j = i;
465    while j > 0 && !s.is_char_boundary(j) {
466        j -= 1;
467    }
468    j
469}
470
471fn char_to_byte(s: &str, ci: usize) -> usize {
472    if ci == 0 {
473        0
474    } else {
475        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
476    }
477}