Skip to main content

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.
61/// `font_family` optionally overrides the default font (e.g. for icons).
62pub fn measure_text(text: &str, font_px: f32, font_family: Option<&'static str>) -> TextMetrics {
63    let m = repose_text::metrics_for_textfield(text, font_px, font_family);
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    match m.byte_offsets.binary_search(&byte) {
72        Ok(i) | Err(i) => i,
73    }
74}
75
76/// Given an x position (px), return the nearest grapheme boundary byte index.
77pub fn index_for_x_bytes(text: &str, font_px: f32, x_px: f32) -> usize {
78    let m = measure_text(text, font_px, None);
79
80    let mut best_i = 0usize;
81    let mut best_d = f32::INFINITY;
82    for i in 0..m.positions.len() {
83        let d = (m.positions[i] - x_px).abs();
84        if d < best_d {
85            best_d = d;
86            best_i = i;
87        }
88    }
89    m.byte_offsets[best_i]
90}
91
92/// find prev/next grapheme boundaries around a byte index
93fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
94    let mut last = 0usize;
95    for (i, _) in text.grapheme_indices(true) {
96        if i >= byte {
97            break;
98        }
99        last = i;
100    }
101    last
102}
103
104fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
105    for (i, _) in text.grapheme_indices(true) {
106        if i > byte {
107            return i;
108        }
109    }
110    text.len()
111}
112
113#[derive(Clone, Debug)]
114pub struct TextFieldState {
115    pub text: String,
116    pub selection: Range<usize>,
117    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
118    pub scroll_offset: f32,                // px (x)
119    pub scroll_offset_y: f32,              // px (y) for multiline
120    pub drag_anchor: Option<usize>,        // byte index where drag began
121    pub blink_start: Instant,              // caret blink timer
122    pub inner_width: f32,                  // px
123    pub inner_height: f32,                 // px
124    pub preferred_x_px: Option<f32>,       // for Up/Down caret movement in multiline
125}
126
127impl Default for TextFieldState {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl TextFieldState {
134    pub fn new() -> Self {
135        Self {
136            text: String::new(),
137            selection: 0..0,
138            composition: None,
139            scroll_offset: 0.0,
140            scroll_offset_y: 0.0,
141            drag_anchor: None,
142            blink_start: Instant::now(),
143            inner_width: 0.0,
144            inner_height: 0.0,
145            preferred_x_px: None,
146        }
147    }
148
149    pub fn insert_text(&mut self, text: &str) {
150        let start = self.selection.start.min(self.text.len());
151        let end = self.selection.end.min(self.text.len());
152
153        self.text.replace_range(start..end, text);
154        let new_pos = start + text.len();
155        self.selection = new_pos..new_pos;
156        self.preferred_x_px = None;
157        self.reset_caret_blink();
158    }
159
160    pub fn delete_backward(&mut self) {
161        if self.selection.start == self.selection.end {
162            let pos = self.selection.start.min(self.text.len());
163            if pos > 0 {
164                let prev = prev_grapheme_boundary(&self.text, pos);
165                self.text.replace_range(prev..pos, "");
166                self.selection = prev..prev;
167            }
168        } else {
169            self.insert_text("");
170        }
171        self.preferred_x_px = None;
172        self.reset_caret_blink();
173    }
174
175    pub fn delete_forward(&mut self) {
176        if self.selection.start == self.selection.end {
177            let pos = self.selection.start.min(self.text.len());
178            if pos < self.text.len() {
179                let next = next_grapheme_boundary(&self.text, pos);
180                self.text.replace_range(pos..next, "");
181            }
182        } else {
183            self.insert_text("");
184        }
185        self.preferred_x_px = None;
186        self.reset_caret_blink();
187    }
188
189    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
190        let mut pos = self.selection.end.min(self.text.len());
191        if delta < 0 {
192            for _ in 0..delta.unsigned_abs() {
193                pos = prev_grapheme_boundary(&self.text, pos);
194            }
195        } else if delta > 0 {
196            for _ in 0..(delta as usize) {
197                pos = next_grapheme_boundary(&self.text, pos);
198            }
199        }
200        if extend_selection {
201            self.selection.end = pos;
202        } else {
203            self.selection = pos..pos;
204        }
205        self.preferred_x_px = None;
206        self.reset_caret_blink();
207    }
208
209    pub fn selected_text(&self) -> String {
210        if self.selection.start == self.selection.end {
211            String::new()
212        } else {
213            self.text[self.selection.clone()].to_string()
214        }
215    }
216
217    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
218        if text.is_empty() {
219            if let Some(range) = self.composition.take() {
220                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
221                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
222                if s <= e {
223                    self.text.replace_range(s..e, "");
224                    self.selection = s..s;
225                }
226            }
227            self.preferred_x_px = None;
228            self.reset_caret_blink();
229            return;
230        }
231
232        let anchor_start;
233        if let Some(r) = self.composition.take() {
234            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
235            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
236            if e < s {
237                std::mem::swap(&mut s, &mut e);
238            }
239            self.text.replace_range(s..e, &text);
240            anchor_start = s;
241        } else {
242            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
243            self.text.insert_str(pos, &text);
244            anchor_start = pos;
245        }
246
247        self.composition = Some(anchor_start..(anchor_start + text.len()));
248
249        if let Some((c0, c1)) = cursor {
250            let b0 = char_to_byte(&text, c0);
251            let b1 = char_to_byte(&text, c1);
252            self.selection = (anchor_start + b0)..(anchor_start + b1);
253        } else {
254            let end = anchor_start + text.len();
255            self.selection = end..end;
256        }
257
258        self.preferred_x_px = None;
259        self.reset_caret_blink();
260    }
261
262    pub fn commit_composition(&mut self, text: String) {
263        if let Some(r) = self.composition.take() {
264            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
265            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
266            self.text.replace_range(s..e, &text);
267            let new_pos = s + text.len();
268            self.selection = new_pos..new_pos;
269        } else {
270            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
271            self.text.insert_str(pos, &text);
272            let new_pos = pos + text.len();
273            self.selection = new_pos..new_pos;
274        }
275        self.preferred_x_px = None;
276        self.reset_caret_blink();
277    }
278
279    pub fn cancel_composition(&mut self) {
280        if let Some(r) = self.composition.take() {
281            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
282            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
283            if s <= e {
284                self.text.replace_range(s..e, "");
285                self.selection = s..s;
286            }
287        }
288        self.preferred_x_px = None;
289        self.reset_caret_blink();
290    }
291
292    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
293        if self.selection.start != self.selection.end {
294            let start = self.selection.start.min(self.text.len());
295            let end = self.selection.end.min(self.text.len());
296            self.text.replace_range(start..end, "");
297            self.selection = start..start;
298            self.preferred_x_px = None;
299            self.reset_caret_blink();
300            return;
301        }
302
303        let caret = self.selection.end.min(self.text.len());
304        let start_raw = caret.saturating_sub(before_bytes);
305        let end_raw = (caret + after_bytes).min(self.text.len());
306
307        let start = prev_grapheme_boundary(&self.text, start_raw);
308        let end = next_grapheme_boundary(&self.text, end_raw);
309        if start < end {
310            self.text.replace_range(start..end, "");
311            self.selection = start..start;
312        }
313        self.preferred_x_px = None;
314        self.reset_caret_blink();
315    }
316
317    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
318        let idx = idx_byte.min(self.text.len());
319        if extend {
320            let anchor = self.selection.start;
321            self.selection = anchor.min(idx)..anchor.max(idx);
322            self.drag_anchor = Some(anchor);
323        } else {
324            self.selection = idx..idx;
325            self.drag_anchor = Some(idx);
326        }
327        self.preferred_x_px = None;
328        self.reset_caret_blink();
329    }
330
331    pub fn drag_to(&mut self, idx_byte: usize) {
332        if let Some(anchor) = self.drag_anchor {
333            let i = idx_byte.min(self.text.len());
334            self.selection = anchor.min(i)..anchor.max(i);
335        }
336        self.preferred_x_px = None;
337        self.reset_caret_blink();
338    }
339    pub fn end_drag(&mut self) {
340        self.drag_anchor = None;
341    }
342
343    pub fn caret_index(&self) -> usize {
344        self.selection.end
345    }
346
347    /// Keep caret visible inside inner content width (px).
348    /// `inset_px` is a small padding (px) to avoid hugging edges.
349    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
350        self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
351    }
352
353    /// Keep caret visible inside an inner rect (for multiline).
354    pub fn ensure_caret_visible_xy(
355        &mut self,
356        caret_x_px: f32,
357        caret_y_px: f32,
358        inner_w_px: f32,
359        inner_h_px: f32,
360        inset_px: f32,
361    ) {
362        let inset_px = inset_px.max(0.0);
363
364        // X
365        let left_px = self.scroll_offset + inset_px;
366        let right_px = self.scroll_offset + inner_w_px - inset_px;
367        if caret_x_px < left_px {
368            self.scroll_offset = (caret_x_px - inset_px).max(0.0);
369        } else if caret_x_px > right_px {
370            self.scroll_offset = (caret_x_px - inner_w_px + inset_px).max(0.0);
371        }
372
373        // Y
374        let top_px = self.scroll_offset_y + inset_px;
375        let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
376        if caret_y_px < top_px {
377            self.scroll_offset_y = (caret_y_px - inset_px).max(0.0);
378        } else if caret_y_px > bot_px {
379            self.scroll_offset_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
380        }
381    }
382
383    pub fn clamp_scroll(&mut self, content_h_px: f32) {
384        let max_y = (content_h_px - self.inner_height).max(0.0);
385        self.scroll_offset_y = self.scroll_offset_y.clamp(0.0, max_y);
386        if self.scroll_offset_y.is_nan() {
387            self.scroll_offset_y = 0.0;
388        }
389    }
390
391    pub fn reset_caret_blink(&mut self) {
392        self.blink_start = Instant::now();
393    }
394    pub fn caret_visible(&self) -> bool {
395        const PERIOD: Duration = Duration::from_millis(500);
396        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
397    }
398
399    pub fn set_inner_width(&mut self, w_px: f32) {
400        self.inner_width = w_px.max(0.0);
401        if self.scroll_offset.is_nan() {
402            self.scroll_offset = 0.0;
403        }
404    }
405    pub fn set_inner_height(&mut self, h_px: f32) {
406        self.inner_height = h_px.max(0.0);
407        if self.scroll_offset_y.is_nan() {
408            self.scroll_offset_y = 0.0;
409        }
410    }
411}
412
413// Platform-managed view: hint shown only when `value` is empty.
414pub fn TextField(
415    hint: impl Into<String>,
416    value: String,
417    modifier: repose_core::Modifier,
418    on_change: Option<impl Fn(String) + 'static>,
419    on_submit: Option<impl Fn(String) + 'static>,
420) -> repose_core::View {
421    repose_core::View::new(
422        0,
423        repose_core::ViewKind::TextField {
424            state_key: 0,
425            hint: hint.into(),
426            on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
427            on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
428            multiline: false,
429            focus_tracker: None,
430            value,
431        },
432    )
433    .modifier(modifier)
434    .semantics(repose_core::Semantics {
435        role: repose_core::Role::TextField,
436        label: None,
437        focused: false,
438        enabled: true,
439    })
440}
441
442/// Platform-managed view: multiline text input.
443/// - Allows '\n' insertion
444/// - Renders wrapped lines + vertical scrolling
445pub fn TextArea(
446    hint: impl Into<String>,
447    value: String,
448    modifier: repose_core::Modifier,
449    on_change: Option<impl Fn(String) + 'static>,
450    on_submit: Option<impl Fn(String) + 'static>,
451) -> repose_core::View {
452    repose_core::View::new(
453        0,
454        repose_core::ViewKind::TextField {
455            state_key: 0,
456            hint: hint.into(),
457            multiline: true,
458            on_change: on_change.map(|f| std::rc::Rc::new(f) as _),
459            on_submit: on_submit.map(|f| std::rc::Rc::new(f) as _),
460            focus_tracker: None,
461            value,
462        },
463    )
464    .modifier(modifier)
465    .semantics(repose_core::Semantics {
466        role: repose_core::Role::TextField,
467        label: None,
468        focused: false,
469        enabled: true,
470    })
471}
472
473#[derive(Clone, Debug)]
474pub struct TextAreaLayout {
475    pub ranges: Vec<(usize, usize)>,
476    pub line_h_px: f32,
477}
478
479pub fn layout_text_area(text: &str, font_px: f32, wrap_w_px: f32) -> TextAreaLayout {
480    let line_h = font_px * 1.3;
481    let (ranges, _) = repose_text::wrap_line_ranges(text, font_px, wrap_w_px.max(1.0), None, true);
482    TextAreaLayout {
483        ranges,
484        line_h_px: line_h,
485    }
486}
487
488/// Return (line_index, local_byte, global_byte) for a global byte index.
489fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
490    if ranges.is_empty() {
491        return (0, 0, b);
492    }
493    for (i, (s, e)) in ranges.iter().enumerate() {
494        if b < *s {
495            if i == 0 {
496                return (0, 0, b);
497            }
498            let (ps, pe) = ranges[i - 1];
499            let local = pe.saturating_sub(ps);
500            return (i - 1, local, ps + local);
501        }
502        if b < *e {
503            let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
504            return (i, local, *s + local);
505        }
506        if b == *e {
507            if let Some((ns, _ne)) = ranges.get(i + 1) {
508                if *ns == b {
509                    return (i + 1, 0, b);
510                }
511            }
512            let local = e.saturating_sub(*s);
513            return (i, local, *s + local);
514        }
515    }
516    let (ls, le) = ranges[ranges.len() - 1];
517    let local = le.saturating_sub(ls);
518    (ranges.len() - 1, local, ls + local)
519}
520
521/// Compute caret (x, y) in px relative to the top-left of the inner content (not scrolled).
522pub fn caret_xy_for_byte(
523    text: &str,
524    font_px: f32,
525    wrap_w_px: f32,
526    byte: usize,
527) -> (f32, f32, usize) {
528    let layout = layout_text_area(text, font_px, wrap_w_px);
529    let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
530    let (li, local, _) = locate_byte_in_ranges(ranges, byte);
531    let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
532    let line = &text[s..e];
533    let m = measure_text(line, font_px, None);
534    let ci = byte_to_char_index(&m, local);
535    let x = m.positions.get(ci).copied().unwrap_or(0.0);
536    let y = (li as f32) * line_h;
537    (x, y, li)
538}
539
540/// Given x/y (px) relative to inner content (not scrolled), return nearest grapheme boundary byte index.
541pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
542    let layout = layout_text_area(text, font_px, wrap_w_px);
543    let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
544    let li = li.min(layout.ranges.len().saturating_sub(1));
545    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
546    let line = &text[s..e];
547    let local = index_for_x_bytes(line, font_px, x_px.max(0.0));
548    (s + local).min(text.len())
549}
550
551/// Move caret up/down in wrapped multiline text, keeping a preferred x column.
552pub fn move_caret_vertical(
553    text: &str,
554    font_px: f32,
555    wrap_w_px: f32,
556    cur_byte: usize,
557    dir: i32, // -1 up, +1 down
558    preferred_x: Option<f32>,
559) -> (usize, f32) {
560    let layout = layout_text_area(text, font_px, wrap_w_px);
561    if layout.ranges.is_empty() {
562        return (cur_byte, preferred_x.unwrap_or(0.0));
563    }
564    let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
565    let px = preferred_x.unwrap_or(x);
566    let mut nli = li as i32 + dir;
567    nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
568    let nli = nli as usize;
569    let (s, e) = layout.ranges[nli];
570    let line = &text[s..e];
571    let local = index_for_x_bytes(line, font_px, px.max(0.0));
572    ((s + local).min(text.len()), px)
573}
574
575/// Move to start/end of current visual line.
576pub fn line_home_end(
577    text: &str,
578    font_px: f32,
579    wrap_w_px: f32,
580    cur_byte: usize,
581    to_end: bool,
582) -> usize {
583    let layout = layout_text_area(text, font_px, wrap_w_px);
584    let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
585    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
586    if to_end { e } else { s }
587}
588
589fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
590    if i >= s.len() {
591        return s.len();
592    }
593    if s.is_char_boundary(i) {
594        return i;
595    }
596    let mut j = i;
597    while j > 0 && !s.is_char_boundary(j) {
598        j -= 1;
599    }
600    j
601}
602
603fn char_to_byte(s: &str, ci: usize) -> usize {
604    if ci == 0 {
605        0
606    } else {
607        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn test_index_for_x_bytes_grapheme() {
617        let t = "A👍🏽B";
618        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
619        let m = measure_text(t, font_px, None);
620        for i in 0..m.byte_offsets.len() - 1 {
621            let b = m.byte_offsets[i];
622            let _ = &t[..b];
623        }
624    }
625}