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