repose_ui/
textfield.rs

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