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