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