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 web_time::Duration;
43use web_time::Instant;
44
45use unicode_segmentation::UnicodeSegmentation;
46
47/// Logical font size for TextField in dp (converted to px at measure/paint time).
48pub const TF_FONT_DP: f32 = 16.0;
49/// Horizontal padding inside the TextField in dp.
50pub const TF_PADDING_X_DP: f32 = 8.0;
51
52pub struct TextMetrics {
53    /// positions[i] = advance up to the i-th grapheme (len == graphemes + 1)
54    pub positions: Vec<f32>, // px
55    /// byte_offsets[i] = byte index of the i-th grapheme (last == text.len())
56    pub byte_offsets: Vec<usize>,
57}
58
59/// Measure caret positions for a single-line textfield using shaping.
60/// `font_px` must match the px size used for rendering the text.
61pub fn measure_text(text: &str, font_px: f32) -> TextMetrics {
62    let m = repose_text::metrics_for_textfield(text, font_px);
63    TextMetrics {
64        positions: m.positions,
65        byte_offsets: m.byte_offsets,
66    }
67}
68
69pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
70    match m.byte_offsets.binary_search(&byte) {
71        Ok(i) | Err(i) => i,
72    }
73}
74
75/// Given an x position (px), return the nearest grapheme boundary byte index.
76pub fn index_for_x_bytes(text: &str, font_px: f32, x_px: f32) -> usize {
77    let m = measure_text(text, font_px);
78
79    let mut best_i = 0usize;
80    let mut best_d = f32::INFINITY;
81    for i in 0..m.positions.len() {
82        let d = (m.positions[i] - x_px).abs();
83        if d < best_d {
84            best_d = d;
85            best_i = i;
86        }
87    }
88    m.byte_offsets[best_i]
89}
90
91/// find prev/next grapheme boundaries around a byte index
92fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
93    let mut last = 0usize;
94    for (i, _) in text.grapheme_indices(true) {
95        if i >= byte {
96            break;
97        }
98        last = i;
99    }
100    last
101}
102
103fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
104    for (i, _) in text.grapheme_indices(true) {
105        if i > byte {
106            return i;
107        }
108    }
109    text.len()
110}
111
112#[derive(Clone, Debug)]
113pub struct TextFieldState {
114    pub text: String,
115    pub selection: Range<usize>,
116    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
117    pub scroll_offset: f32,                // px
118    pub drag_anchor: Option<usize>,        // byte index where drag began
119    pub blink_start: Instant,              // caret blink timer
120    pub inner_width: f32,                  // px
121}
122
123impl Default for TextFieldState {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl TextFieldState {
130    pub fn new() -> Self {
131        Self {
132            text: String::new(),
133            selection: 0..0,
134            composition: None,
135            scroll_offset: 0.0,
136            drag_anchor: None,
137            blink_start: Instant::now(),
138            inner_width: 0.0,
139        }
140    }
141
142    pub fn insert_text(&mut self, text: &str) {
143        let start = self.selection.start.min(self.text.len());
144        let end = self.selection.end.min(self.text.len());
145
146        self.text.replace_range(start..end, text);
147        let new_pos = start + text.len();
148        self.selection = new_pos..new_pos;
149        self.reset_caret_blink();
150    }
151
152    pub fn delete_backward(&mut self) {
153        if self.selection.start == self.selection.end {
154            let pos = self.selection.start.min(self.text.len());
155            if pos > 0 {
156                let prev = prev_grapheme_boundary(&self.text, pos);
157                self.text.replace_range(prev..pos, "");
158                self.selection = prev..prev;
159            }
160        } else {
161            self.insert_text("");
162        }
163        self.reset_caret_blink();
164    }
165
166    pub fn delete_forward(&mut self) {
167        if self.selection.start == self.selection.end {
168            let pos = self.selection.start.min(self.text.len());
169            if pos < self.text.len() {
170                let next = next_grapheme_boundary(&self.text, pos);
171                self.text.replace_range(pos..next, "");
172            }
173        } else {
174            self.insert_text("");
175        }
176        self.reset_caret_blink();
177    }
178
179    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
180        let mut pos = self.selection.end.min(self.text.len());
181        if delta < 0 {
182            for _ in 0..delta.unsigned_abs() {
183                pos = prev_grapheme_boundary(&self.text, pos);
184            }
185        } else if delta > 0 {
186            for _ in 0..(delta as usize) {
187                pos = next_grapheme_boundary(&self.text, pos);
188            }
189        }
190        if extend_selection {
191            self.selection.end = pos;
192        } else {
193            self.selection = pos..pos;
194        }
195        self.reset_caret_blink();
196    }
197
198    pub fn selected_text(&self) -> String {
199        if self.selection.start == self.selection.end {
200            String::new()
201        } else {
202            self.text[self.selection.clone()].to_string()
203        }
204    }
205
206    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
207        if text.is_empty() {
208            if let Some(range) = self.composition.take() {
209                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
210                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
211                if s <= e {
212                    self.text.replace_range(s..e, "");
213                    self.selection = s..s;
214                }
215            }
216            self.reset_caret_blink();
217            return;
218        }
219
220        let anchor_start;
221        if let Some(r) = self.composition.take() {
222            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
223            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
224            if e < s {
225                std::mem::swap(&mut s, &mut e);
226            }
227            self.text.replace_range(s..e, &text);
228            anchor_start = s;
229        } else {
230            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
231            self.text.insert_str(pos, &text);
232            anchor_start = pos;
233        }
234
235        self.composition = Some(anchor_start..(anchor_start + text.len()));
236
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            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
258            self.text.insert_str(pos, &text);
259            let new_pos = pos + text.len();
260            self.selection = new_pos..new_pos;
261        }
262        self.reset_caret_blink();
263    }
264
265    pub fn cancel_composition(&mut self) {
266        if let Some(r) = self.composition.take() {
267            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
268            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
269            if s <= e {
270                self.text.replace_range(s..e, "");
271                self.selection = s..s;
272            }
273        }
274        self.reset_caret_blink();
275    }
276
277    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
278        if self.selection.start != self.selection.end {
279            let start = self.selection.start.min(self.text.len());
280            let end = self.selection.end.min(self.text.len());
281            self.text.replace_range(start..end, "");
282            self.selection = start..start;
283            self.reset_caret_blink();
284            return;
285        }
286
287        let caret = self.selection.end.min(self.text.len());
288        let start_raw = caret.saturating_sub(before_bytes);
289        let end_raw = (caret + after_bytes).min(self.text.len());
290
291        let start = prev_grapheme_boundary(&self.text, start_raw);
292        let end = next_grapheme_boundary(&self.text, end_raw);
293        if start < end {
294            self.text.replace_range(start..end, "");
295            self.selection = start..start;
296        }
297        self.reset_caret_blink();
298    }
299
300    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
301        let idx = idx_byte.min(self.text.len());
302        if extend {
303            let anchor = self.selection.start;
304            self.selection = anchor.min(idx)..anchor.max(idx);
305            self.drag_anchor = Some(anchor);
306        } else {
307            self.selection = idx..idx;
308            self.drag_anchor = Some(idx);
309        }
310        self.reset_caret_blink();
311    }
312
313    pub fn drag_to(&mut self, idx_byte: usize) {
314        if let Some(anchor) = self.drag_anchor {
315            let i = idx_byte.min(self.text.len());
316            self.selection = anchor.min(i)..anchor.max(i);
317        }
318        self.reset_caret_blink();
319    }
320    pub fn end_drag(&mut self) {
321        self.drag_anchor = None;
322    }
323
324    pub fn caret_index(&self) -> usize {
325        self.selection.end
326    }
327
328    /// Keep caret visible inside inner content width (px).
329    /// `inset_px` is a small padding (px) to avoid hugging edges.
330    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
331        let inset_px = inset_px.max(0.0);
332        let left_px = self.scroll_offset + inset_px;
333        let right_px = self.scroll_offset + inner_width_px - inset_px;
334        if caret_x_px < left_px {
335            self.scroll_offset = (caret_x_px - inset_px).max(0.0);
336        } else if caret_x_px > right_px {
337            self.scroll_offset = (caret_x_px - inner_width_px + inset_px).max(0.0);
338        }
339    }
340
341    pub fn reset_caret_blink(&mut self) {
342        self.blink_start = Instant::now();
343    }
344    pub fn caret_visible(&self) -> bool {
345        const PERIOD: Duration = Duration::from_millis(500);
346        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
347    }
348
349    pub fn set_inner_width(&mut self, w_px: f32) {
350        self.inner_width = w_px.max(0.0);
351    }
352}
353
354// Platform-managed view: hint only.
355pub fn TextField(
356    hint: impl Into<String>,
357    modifier: repose_core::Modifier,
358    on_change: Option<impl Fn(String) + 'static>,
359    on_submit: Option<impl Fn(String) + 'static>,
360) -> repose_core::View {
361    repose_core::View::new(
362        0,
363        repose_core::ViewKind::TextField {
364            state_key: 0,
365            hint: hint.into(),
366            on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
367            on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
368        },
369    )
370    .modifier(modifier)
371    .semantics(repose_core::Semantics {
372        role: repose_core::Role::TextField,
373        label: None,
374        focused: false,
375        enabled: true,
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_index_for_x_bytes_grapheme() {
385        let t = "AšŸ‘šŸ½B";
386        let font_px = 16.0; // in tests, exact px isn't important—boundaries are.
387        let m = measure_text(t, font_px);
388        for i in 0..m.byte_offsets.len() - 1 {
389            let b = m.byte_offsets[i];
390            let _ = &t[..b];
391        }
392    }
393}
394
395fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
396    if i >= s.len() {
397        return s.len();
398    }
399    if s.is_char_boundary(i) {
400        return i;
401    }
402    let mut j = i;
403    while j > 0 && !s.is_char_boundary(j) {
404        j -= 1;
405    }
406    j
407}
408
409fn char_to_byte(s: &str, ci: usize) -> usize {
410    if ci == 0 {
411        0
412    } else {
413        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
414    }
415}