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,ignore
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::cell::RefCell;
42use std::ops::Range;
43use std::rc::Rc;
44use std::sync::Arc;
45use unicode_segmentation::UnicodeSegmentation;
46use web_time::Duration;
47use web_time::Instant;
48
49/// Maximum number of undo/redo operations stored in history.
50const TEXT_UNDO_CAPACITY: usize = 100;
51
52/// Time window (ms) within which consecutive operations can be merged.
53const SNAPSHOTS_INTERVAL_MILLIS: u128 = 5000;
54
55/// Type of text edit operation.
56#[derive(Clone, Copy, Debug, PartialEq)]
57enum TextEditType {
58    Insert,
59    Delete,
60    Replace,
61}
62
63/// Direction of a deletion.
64#[derive(Clone, Copy, Debug, PartialEq)]
65enum TextDeleteType {
66    Start, // backspace: cursor moving towards start
67    End,   // delete forward: cursor moving towards end
68    Inner, // selection removed
69    NotByUser,
70}
71
72/// A single atomic text change that can be undone/redone.
73#[derive(Clone, Debug)]
74pub struct TextUndoOp {
75    /// Start point of the change in the text.
76    pub index: usize,
77    /// Text that was present before the change (being replaced/deleted).
78    pub pre_text: String,
79    /// Text that was inserted (replacing pre_text).
80    pub post_text: String,
81    /// Selection before the change.
82    pub pre_selection: Range<usize>,
83    /// Selection after the change.
84    pub post_selection: Range<usize>,
85    /// When this change was first committed.
86    pub time: Instant,
87    /// Whether this change can merge with adjacent operations.
88    pub can_merge: bool,
89}
90
91impl TextUndoOp {
92    fn edit_type(&self) -> TextEditType {
93        match (self.pre_text.is_empty(), self.post_text.is_empty()) {
94            (true, true) => unreachable!("Both pre and post text cannot be empty"),
95            (true, false) => TextEditType::Insert,
96            (false, true) => TextEditType::Delete,
97            (false, false) => TextEditType::Replace,
98        }
99    }
100
101    fn is_newline(&self) -> bool {
102        self.post_text == "\n" || self.post_text == "\r\n"
103    }
104
105    /// Try to merge `self` (earlier) with `next` (later). Returns merged op if merge is possible.
106    fn try_merge(&self, next: &TextUndoOp) -> Option<TextUndoOp> {
107        if !self.can_merge || !next.can_merge {
108            return None;
109        }
110
111        let elapsed = next.time.saturating_duration_since(self.time);
112        if elapsed.as_millis() >= SNAPSHOTS_INTERVAL_MILLIS {
113            return None;
114        }
115
116        if self.is_newline() || next.is_newline() {
117            return None;
118        }
119
120        let self_type = self.edit_type();
121        if self_type != next.edit_type() {
122            return None;
123        }
124
125        match self_type {
126            TextEditType::Insert => {
127                // Only merge if next insertion continues from the end of this one
128                if self.index + self.post_text.len() == next.index {
129                    Some(TextUndoOp {
130                        index: self.index,
131                        pre_text: String::new(),
132                        post_text: format!("{}{}", self.post_text, next.post_text),
133                        pre_selection: self.pre_selection.clone(),
134                        post_selection: next.post_selection.clone(),
135                        time: self.time,
136                        can_merge: true,
137                    })
138                } else {
139                    None
140                }
141            }
142            TextEditType::Delete => {
143                let self_del = self.deletion_type();
144                let next_del = next.deletion_type();
145                // Only merge consecutive deletions with same directionality
146                if self_del == next_del
147                    && (self_del == TextDeleteType::Start || self_del == TextDeleteType::End)
148                {
149                    if self.index == next.index + next.pre_text.len() {
150                        // This op is after next (backspace: deleting right-to-left)
151                        Some(TextUndoOp {
152                            index: next.index,
153                            pre_text: format!("{}{}", next.pre_text, self.pre_text),
154                            post_text: String::new(),
155                            pre_selection: self.pre_selection.clone(),
156                            post_selection: next.post_selection.clone(),
157                            time: self.time,
158                            can_merge: true,
159                        })
160                    } else if self.index == next.index {
161                        // Same position (delete forward: deleting left-to-right)
162                        Some(TextUndoOp {
163                            index: self.index,
164                            pre_text: format!("{}{}", self.pre_text, next.pre_text),
165                            post_text: String::new(),
166                            pre_selection: self.pre_selection.clone(),
167                            post_selection: next.post_selection.clone(),
168                            time: self.time,
169                            can_merge: true,
170                        })
171                    } else {
172                        None
173                    }
174                } else {
175                    None
176                }
177            }
178            TextEditType::Replace => None,
179        }
180    }
181
182    /// Determine the deletion direction. Only meaningful when edit_type is Delete.
183    fn deletion_type(&self) -> TextDeleteType {
184        if self.edit_type() != TextEditType::Delete {
185            return TextDeleteType::NotByUser;
186        }
187        if !self.post_selection.start == self.post_selection.end {
188            return TextDeleteType::NotByUser;
189        }
190        if self.pre_selection.start == self.pre_selection.end {
191            // Collapsed selection before delete: cursor moved
192            if self.pre_selection.start > self.post_selection.start {
193                TextDeleteType::Start // backspace
194            } else {
195                TextDeleteType::End // delete forward
196            }
197        } else if self.pre_selection.start == self.post_selection.start
198            && self.pre_selection.start == self.index
199        {
200            TextDeleteType::Inner
201        } else {
202            TextDeleteType::NotByUser
203        }
204    }
205}
206
207/// Spring physics constants for smooth scroll animation.
208const SCROLL_STIFFNESS: f32 = 300.0;
209const SCROLL_DAMPING: f32 = 30.0;
210
211/// Logical font size for TextField in dp (converted to px at measure/paint time).
212pub const TF_FONT_DP: f32 = 16.0;
213
214/// Configures the keyboard for a text field.
215#[derive(Clone, Copy, Debug)]
216pub struct KeyboardOptions {
217    pub keyboard_type: repose_core::KeyboardType,
218    pub autocorrect: bool,
219}
220
221impl Default for KeyboardOptions {
222    fn default() -> Self {
223        Self {
224            keyboard_type: repose_core::KeyboardType::default(),
225            autocorrect: true,
226        }
227    }
228}
229/// Horizontal padding inside the TextField in dp.
230pub const TF_PADDING_X_DP: f32 = 8.0;
231
232pub struct TextMetrics {
233    /// positions[i] = advance up to the i-th grapheme (len == graphemes + 1)
234    pub positions: Vec<f32>, // px
235    /// byte_offsets[i] = byte index of the i-th grapheme (last == text.len())
236    pub byte_offsets: Vec<usize>,
237}
238
239/// Measure caret positions for a single-line textfield using shaping.
240/// `font_px` must match the px size used for rendering the text.
241/// `font_family` optionally overrides the default font (e.g. for icons).
242pub fn measure_text(text: &str, font_px: f32, font_family: Option<&'static str>) -> TextMetrics {
243    let m = repose_text::metrics_for_textfield(text, font_px, font_family);
244    TextMetrics {
245        positions: m.positions,
246        byte_offsets: m.byte_offsets,
247    }
248}
249
250pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
251    match m.byte_offsets.binary_search(&byte) {
252        Ok(i) | Err(i) => i,
253    }
254}
255
256/// Given an x position (px), return the nearest grapheme boundary byte index.
257pub fn index_for_x_bytes(text: &str, font_px: f32, x_px: f32) -> usize {
258    let m = measure_text(text, font_px, None);
259
260    let mut best_i = 0usize;
261    let mut best_d = f32::INFINITY;
262    for i in 0..m.positions.len() {
263        let d = (m.positions[i] - x_px).abs();
264        if d < best_d {
265            best_d = d;
266            best_i = i;
267        }
268    }
269    m.byte_offsets[best_i]
270}
271
272/// find prev/next grapheme boundaries around a byte index
273fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
274    let mut last = 0usize;
275    for (i, _) in text.grapheme_indices(true) {
276        if i >= byte {
277            break;
278        }
279        last = i;
280    }
281    last
282}
283
284fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
285    for (i, _) in text.grapheme_indices(true) {
286        if i > byte {
287            return i;
288        }
289    }
290    text.len()
291}
292
293#[derive(Clone)]
294pub struct TextFieldState {
295    pub text: String,
296    pub selection: Range<usize>,
297    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
298    pub scroll_offset: f32,                // px (x) - current animated display value
299    pub scroll_offset_y: f32,              // px (y) for multiline - current animated display value
300    pub drag_anchor: Option<usize>,        // byte index where drag began
301    pub blink_start: Instant,              // caret blink timer
302    pub inner_width: f32,                  // px
303    pub inner_height: f32,                 // px
304    pub preferred_x_px: Option<f32>,       // for Up/Down caret movement in multiline
305    /// When a visual transformation is active, this maps offsets in the
306    /// display text back to offsets in the original text.
307    pub offset_map: Option<Rc<dyn Fn(usize) -> usize>>,
308    /// The active visual transformation, set during layout.
309    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
310    /// Target horizontal scroll offset (where we're animating toward).
311    pub(crate) scroll_target: f32,
312    /// Target vertical scroll offset.
313    pub(crate) scroll_target_y: f32,
314    /// Spring velocity for horizontal scroll animation.
315    scroll_vel: f32,
316    /// Spring velocity for vertical scroll animation.
317    scroll_vel_y: f32,
318    /// Last time tick_scroll_animation was called (for dt computation).
319    last_scroll_tick: Option<Instant>,
320
321    // --- Undo/Redo ---
322    /// Stack of undo operations (most recent at end).
323    undo_stack: Vec<TextUndoOp>,
324    /// Stack of redo operations (most recent at end).
325    redo_stack: Vec<TextUndoOp>,
326    /// Staging area for the latest operation that may still merge.
327    staging_undo: Option<TextUndoOp>,
328}
329
330impl std::fmt::Debug for TextFieldState {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        f.debug_struct("TextFieldState")
333            .field("text", &self.text)
334            .field("selection", &self.selection)
335            .field("composition", &self.composition)
336            .field("scroll_offset", &self.scroll_offset)
337            .field("scroll_offset_y", &self.scroll_offset_y)
338            .field("drag_anchor", &self.drag_anchor)
339            .field("blink_start", &self.blink_start)
340            .field("inner_width", &self.inner_width)
341            .field("inner_height", &self.inner_height)
342            .field("preferred_x_px", &self.preferred_x_px)
343            .field("offset_map", &self.offset_map.as_ref().map(|_| "<fn>"))
344            .field(
345                "visual_transformation",
346                &self.visual_transformation.as_ref().map(|_| "<vt>"),
347            )
348            .field("scroll_target", &self.scroll_target)
349            .field("scroll_target_y", &self.scroll_target_y)
350            .field("can_undo", &self.can_undo())
351            .field("can_redo", &self.can_redo())
352            .field("undo_count", &self.undo_stack.len())
353            .field("redo_count", &self.redo_stack.len())
354            .finish()
355    }
356}
357
358impl Default for TextFieldState {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl TextFieldState {
365    pub fn new() -> Self {
366        Self {
367            text: String::new(),
368            selection: 0..0,
369            composition: None,
370            scroll_offset: 0.0,
371            scroll_offset_y: 0.0,
372            drag_anchor: None,
373            blink_start: Instant::now(),
374            inner_width: 0.0,
375            inner_height: 0.0,
376            preferred_x_px: None,
377            offset_map: None,
378            visual_transformation: None,
379            scroll_target: 0.0,
380            scroll_target_y: 0.0,
381            scroll_vel: 0.0,
382            scroll_vel_y: 0.0,
383            last_scroll_tick: None,
384            undo_stack: Vec::new(),
385            redo_stack: Vec::new(),
386            staging_undo: None,
387        }
388    }
389
390    // --- Undo/Redo ---
391
392    /// Whether there is an action to undo.
393    pub fn can_undo(&self) -> bool {
394        !self.undo_stack.is_empty() || self.staging_undo.is_some()
395    }
396
397    /// Whether there is an action to redo.
398    pub fn can_redo(&self) -> bool {
399        !self.redo_stack.is_empty()
400    }
401
402    /// Revert the latest edit. Returns true if an undo was performed.
403    pub fn undo(&mut self) -> bool {
404        self.flush_undo();
405        if let Some(op) = self.undo_stack.pop() {
406            let end = (op.index + op.post_text.len()).min(self.text.len());
407            self.text.replace_range(op.index..end, &op.pre_text);
408            self.selection = op.pre_selection.clone();
409            self.redo_stack.push(op);
410            self.preferred_x_px = None;
411            self.reset_caret_blink();
412            true
413        } else {
414            false
415        }
416    }
417
418    /// Re-apply a previously undone edit. Returns true if a redo was performed.
419    pub fn redo(&mut self) -> bool {
420        if let Some(op) = self.redo_stack.pop() {
421            let end = (op.index + op.pre_text.len()).min(self.text.len());
422            self.text.replace_range(op.index..end, &op.post_text);
423            self.selection = op.post_selection.clone();
424            self.undo_stack.push(op);
425            self.preferred_x_px = None;
426            self.reset_caret_blink();
427            true
428        } else {
429            false
430        }
431    }
432
433    /// Clear all undo/redo history.
434    pub fn clear_undo_history(&mut self) {
435        self.undo_stack.clear();
436        self.redo_stack.clear();
437        self.staging_undo = None;
438    }
439
440    /// Push a [TextUndoOp] to the staging area, possibly merging with the
441    /// previous staging operation. Flushes staging to the undo stack when
442    /// merge is not possible.
443    fn record_edit(&mut self, op: TextUndoOp) {
444        if let Some(staging) = self.staging_undo.take() {
445            if let Some(merged) = staging.try_merge(&op) {
446                self.staging_undo = Some(merged);
447                return;
448            }
449            // Can't merge: flush staging to undo stack
450            self.undo_stack.push(staging);
451            self.redo_stack.clear();
452            // Enforce capacity: drop oldest entries
453            while self.undo_stack.len() + 1 > TEXT_UNDO_CAPACITY {
454                self.undo_stack.remove(0);
455            }
456        }
457        self.staging_undo = Some(op);
458    }
459
460    /// Flush the staging operation into the undo stack.
461    fn flush_undo(&mut self) {
462        if let Some(op) = self.staging_undo.take() {
463            self.undo_stack.push(op);
464            self.redo_stack.clear();
465            while self.undo_stack.len() > TEXT_UNDO_CAPACITY {
466                self.undo_stack.remove(0);
467            }
468        }
469    }
470
471    fn insert_text_impl(&mut self, text: &str, can_merge: bool) {
472        let start = self.selection.start.min(self.text.len());
473        let end = self.selection.end.min(self.text.len());
474        let pre_text = self.text[start..end].to_string();
475        let pre_selection = self.selection.clone();
476
477        self.text.replace_range(start..end, text);
478        let new_pos = start + text.len();
479        self.selection = new_pos..new_pos;
480        self.preferred_x_px = None;
481        self.reset_caret_blink();
482
483        if !pre_text.is_empty() || !text.is_empty() {
484            self.record_edit(TextUndoOp {
485                index: start,
486                pre_text,
487                post_text: text.to_string(),
488                pre_selection,
489                post_selection: self.selection.clone(),
490                time: Instant::now(),
491                can_merge,
492            });
493        }
494    }
495
496    pub fn insert_text(&mut self, text: &str) {
497        self.insert_text_impl(text, true);
498    }
499
500    /// Like `insert_text` but marks the operation as unmergeable (for cut/paste).
501    pub fn insert_text_atomic(&mut self, text: &str) {
502        self.insert_text_impl(text, false);
503    }
504
505    pub fn delete_backward(&mut self) {
506        if self.selection.start == self.selection.end {
507            let pos = self.selection.start.min(self.text.len());
508            if pos > 0 {
509                let prev = prev_grapheme_boundary(&self.text, pos);
510                let pre_text = self.text[prev..pos].to_string();
511                let pre_selection = self.selection.clone();
512                self.text.replace_range(prev..pos, "");
513                self.selection = prev..prev;
514                self.preferred_x_px = None;
515                self.reset_caret_blink();
516                self.record_edit(TextUndoOp {
517                    index: prev,
518                    pre_text,
519                    post_text: String::new(),
520                    pre_selection,
521                    post_selection: self.selection.clone(),
522                    time: Instant::now(),
523                    can_merge: true,
524                });
525            }
526        } else {
527            self.insert_text_impl("", true);
528        }
529        self.preferred_x_px = None;
530        self.reset_caret_blink();
531    }
532
533    pub fn delete_forward(&mut self) {
534        if self.selection.start == self.selection.end {
535            let pos = self.selection.start.min(self.text.len());
536            if pos < self.text.len() {
537                let next = next_grapheme_boundary(&self.text, pos);
538                let pre_text = self.text[pos..next].to_string();
539                let pre_selection = self.selection.clone();
540                self.text.replace_range(pos..next, "");
541                self.preferred_x_px = None;
542                self.reset_caret_blink();
543                self.record_edit(TextUndoOp {
544                    index: pos,
545                    pre_text,
546                    post_text: String::new(),
547                    pre_selection,
548                    post_selection: self.selection.clone(),
549                    time: Instant::now(),
550                    can_merge: true,
551                });
552            }
553        } else {
554            self.insert_text_impl("", true);
555        }
556        self.preferred_x_px = None;
557        self.reset_caret_blink();
558    }
559
560    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
561        let mut pos = self.selection.end.min(self.text.len());
562        if delta < 0 {
563            for _ in 0..delta.unsigned_abs() {
564                pos = prev_grapheme_boundary(&self.text, pos);
565            }
566        } else if delta > 0 {
567            for _ in 0..(delta as usize) {
568                pos = next_grapheme_boundary(&self.text, pos);
569            }
570        }
571        if extend_selection {
572            self.selection.end = pos;
573        } else {
574            self.selection = pos..pos;
575        }
576        self.preferred_x_px = None;
577        self.reset_caret_blink();
578    }
579
580    pub fn selected_text(&self) -> String {
581        if self.selection.start == self.selection.end {
582            String::new()
583        } else {
584            self.text[self.selection.clone()].to_string()
585        }
586    }
587
588    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
589        if text.is_empty() {
590            if let Some(range) = self.composition.take() {
591                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
592                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
593                if s <= e {
594                    self.text.replace_range(s..e, "");
595                    self.selection = s..s;
596                }
597            }
598            self.preferred_x_px = None;
599            self.reset_caret_blink();
600            return;
601        }
602
603        let anchor_start;
604        if let Some(r) = self.composition.take() {
605            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
606            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
607            if e < s {
608                std::mem::swap(&mut s, &mut e);
609            }
610            self.text.replace_range(s..e, &text);
611            anchor_start = s;
612        } else {
613            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
614            self.text.insert_str(pos, &text);
615            anchor_start = pos;
616        }
617
618        self.composition = Some(anchor_start..(anchor_start + text.len()));
619
620        if let Some((c0, c1)) = cursor {
621            let b0 = char_to_byte(&text, c0);
622            let b1 = char_to_byte(&text, c1);
623            self.selection = (anchor_start + b0)..(anchor_start + b1);
624        } else {
625            let end = anchor_start + text.len();
626            self.selection = end..end;
627        }
628
629        self.preferred_x_px = None;
630        self.reset_caret_blink();
631    }
632
633    pub fn commit_composition(&mut self, text: String) {
634        let pre_selection = self.selection.clone();
635        if let Some(r) = self.composition.take() {
636            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
637            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
638            let pre_text = self.text[s..e].to_string();
639            self.text.replace_range(s..e, &text);
640            let new_pos = s + text.len();
641            self.selection = new_pos..new_pos;
642            self.preferred_x_px = None;
643            self.reset_caret_blink();
644            if !pre_text.is_empty() || !text.is_empty() {
645                self.record_edit(TextUndoOp {
646                    index: s,
647                    pre_text,
648                    post_text: text,
649                    pre_selection,
650                    post_selection: self.selection.clone(),
651                    time: Instant::now(),
652                    can_merge: true,
653                });
654            }
655        } else {
656            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
657            self.text.insert_str(pos, &text);
658            let new_pos = pos + text.len();
659            self.selection = new_pos..new_pos;
660            self.preferred_x_px = None;
661            self.reset_caret_blink();
662            if !text.is_empty() {
663                self.record_edit(TextUndoOp {
664                    index: pos,
665                    pre_text: String::new(),
666                    post_text: text,
667                    pre_selection,
668                    post_selection: self.selection.clone(),
669                    time: Instant::now(),
670                    can_merge: true,
671                });
672            }
673        }
674    }
675
676    pub fn cancel_composition(&mut self) {
677        if let Some(r) = self.composition.take() {
678            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
679            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
680            if s <= e {
681                self.text.replace_range(s..e, "");
682                self.selection = s..s;
683            }
684        }
685        self.preferred_x_px = None;
686        self.reset_caret_blink();
687    }
688
689    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
690        if self.selection.start != self.selection.end {
691            let start = self.selection.start.min(self.text.len());
692            let end = self.selection.end.min(self.text.len());
693            self.text.replace_range(start..end, "");
694            self.selection = start..start;
695            self.preferred_x_px = None;
696            self.reset_caret_blink();
697            return;
698        }
699
700        let caret = self.selection.end.min(self.text.len());
701        let start_raw = caret.saturating_sub(before_bytes);
702        let end_raw = (caret + after_bytes).min(self.text.len());
703
704        let start = prev_grapheme_boundary(&self.text, start_raw);
705        let end = next_grapheme_boundary(&self.text, end_raw);
706        if start < end {
707            self.text.replace_range(start..end, "");
708            self.selection = start..start;
709        }
710        self.preferred_x_px = None;
711        self.reset_caret_blink();
712    }
713
714    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
715        let idx = idx_byte.min(self.text.len());
716        if extend {
717            let anchor = self.selection.start;
718            self.selection = anchor.min(idx)..anchor.max(idx);
719            self.drag_anchor = Some(anchor);
720        } else {
721            self.selection = idx..idx;
722            self.drag_anchor = Some(idx);
723        }
724        self.preferred_x_px = None;
725        self.reset_caret_blink();
726    }
727
728    pub fn drag_to(&mut self, idx_byte: usize) {
729        if let Some(anchor) = self.drag_anchor {
730            let i = idx_byte.min(self.text.len());
731            self.selection = anchor.min(i)..anchor.max(i);
732        }
733        self.preferred_x_px = None;
734        self.reset_caret_blink();
735    }
736    pub fn end_drag(&mut self) {
737        self.drag_anchor = None;
738    }
739
740    pub fn caret_index(&self) -> usize {
741        self.selection.end
742    }
743
744    /// Keep caret visible inside inner content width (px).
745    /// `inset_px` is a small padding (px) to avoid hugging edges.
746    /// Sets the scroll target for smooth animated scrolling.
747    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
748        self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
749    }
750
751    /// Keep caret visible inside an inner rect (for multiline).
752    /// Sets the scroll target for smooth animated scrolling.
753    pub fn ensure_caret_visible_xy(
754        &mut self,
755        caret_x_px: f32,
756        caret_y_px: f32,
757        inner_w_px: f32,
758        inner_h_px: f32,
759        inset_px: f32,
760    ) {
761        let inset_px = inset_px.max(0.0);
762
763        // Compute target X scroll based on current display offset
764        let left_px = self.scroll_offset + inset_px;
765        let right_px = self.scroll_offset + inner_w_px - inset_px;
766        if caret_x_px < left_px {
767            self.scroll_target = (caret_x_px - inset_px).max(0.0);
768        } else if caret_x_px > right_px {
769            self.scroll_target = (caret_x_px - inner_w_px + inset_px).max(0.0);
770        }
771
772        // Compute target Y scroll based on current display offset
773        let top_px = self.scroll_offset_y + inset_px;
774        let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
775        if caret_y_px < top_px {
776            self.scroll_target_y = (caret_y_px - inset_px).max(0.0);
777        } else if caret_y_px > bot_px {
778            self.scroll_target_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
779        }
780    }
781
782    pub fn clamp_scroll(&mut self, content_h_px: f32) {
783        let max_y = (content_h_px - self.inner_height).max(0.0);
784        self.scroll_target_y = self.scroll_target_y.clamp(0.0, max_y);
785        if self.scroll_target_y.is_nan() {
786            self.scroll_target_y = 0.0;
787        }
788    }
789
790    pub fn reset_caret_blink(&mut self) {
791        self.blink_start = Instant::now();
792    }
793    pub fn caret_visible(&self) -> bool {
794        const PERIOD: Duration = Duration::from_millis(500);
795        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
796    }
797
798    pub fn set_inner_width(&mut self, w_px: f32) {
799        self.inner_width = w_px.max(0.0);
800        if self.scroll_offset.is_nan() {
801            self.scroll_offset = 0.0;
802        }
803        if self.scroll_target.is_nan() {
804            self.scroll_target = 0.0;
805        }
806    }
807    pub fn set_inner_height(&mut self, h_px: f32) {
808        self.inner_height = h_px.max(0.0);
809        if self.scroll_offset_y.is_nan() {
810            self.scroll_offset_y = 0.0;
811        }
812        if self.scroll_target_y.is_nan() {
813            self.scroll_target_y = 0.0;
814        }
815    }
816
817    /// Advance scroll animation by actual wall-clock dt using spring physics.
818    /// Call this once per frame before reading [scroll_offset] / [scroll_offset_y].
819    /// On the first call after a target change, snaps immediately to avoid 1-frame delay.
820    pub fn tick_scroll_animation(&mut self) {
821        let now = Instant::now();
822        let dt = match self.last_scroll_tick {
823            Some(prev) => {
824                let d = now.saturating_duration_since(prev).as_secs_f32();
825                d.min(0.05) // cap to 50ms to avoid jumps after pause
826            }
827            None => {
828                // First tick: snap to target immediately, but record the time
829                // so subsequent ticks produce a smooth spring.
830                self.last_scroll_tick = Some(now);
831                self.scroll_offset = self.scroll_target;
832                self.scroll_vel = 0.0;
833                self.scroll_offset_y = self.scroll_target_y;
834                self.scroll_vel_y = 0.0;
835                return;
836            }
837        };
838        self.last_scroll_tick = Some(now);
839
840        // X axis
841        if dt > 0.0 {
842            let dx = self.scroll_target - self.scroll_offset;
843            let near_x = dx.abs() < 0.5 && self.scroll_vel.abs() < 0.5;
844            if near_x {
845                self.scroll_offset = self.scroll_target;
846                self.scroll_vel = 0.0;
847            } else {
848                let force_x = SCROLL_STIFFNESS * dx - SCROLL_DAMPING * self.scroll_vel;
849                self.scroll_vel += force_x * dt;
850                self.scroll_offset += self.scroll_vel * dt;
851                // Overshoot protection: clamp to target if we'd pass it this frame
852                if (self.scroll_target - self.scroll_offset).signum() != dx.signum() && dx != 0.0 {
853                    self.scroll_offset = self.scroll_target;
854                    self.scroll_vel = 0.0;
855                }
856            }
857        }
858
859        // Y axis
860        if dt > 0.0 {
861            let dy = self.scroll_target_y - self.scroll_offset_y;
862            let near_y = dy.abs() < 0.5 && self.scroll_vel_y.abs() < 0.5;
863            if near_y {
864                self.scroll_offset_y = self.scroll_target_y;
865                self.scroll_vel_y = 0.0;
866            } else {
867                let force_y = SCROLL_STIFFNESS * dy - SCROLL_DAMPING * self.scroll_vel_y;
868                self.scroll_vel_y += force_y * dt;
869                self.scroll_offset_y += self.scroll_vel_y * dt;
870                if (self.scroll_target_y - self.scroll_offset_y).signum() != dy.signum()
871                    && dy != 0.0
872                {
873                    self.scroll_offset_y = self.scroll_target_y;
874                    self.scroll_vel_y = 0.0;
875                }
876            }
877        }
878    }
879}
880
881// Platform-managed view: hint shown only when `value` is empty.
882pub fn TextField(
883    hint: impl Into<String>,
884    value: String,
885    modifier: repose_core::Modifier,
886    on_change: Option<impl Fn(String) + 'static>,
887    on_submit: Option<impl Fn(String) + 'static>,
888) -> repose_core::View {
889    TextFieldEx {
890        hint: hint.into(),
891        value,
892        modifier,
893        on_change: on_change.map(|f| Rc::new(f) as _),
894        on_submit: on_submit.map(|f| Rc::new(f) as _),
895        visual_transformation: None,
896        keyboard_options: None,
897        ime_action: None,
898    }
899    .build()
900}
901
902/// Extended TextField with optional visual transformation, keyboard options, and IME action.
903pub struct TextFieldEx {
904    pub hint: String,
905    pub value: String,
906    pub modifier: repose_core::Modifier,
907    pub on_change: Option<Rc<dyn Fn(String)>>,
908    pub on_submit: Option<Rc<dyn Fn(String)>>,
909    /// Optional visual transformation (e.g., password masking).
910    pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
911    /// Platform keyboard configuration hints.
912    pub keyboard_options: Option<KeyboardOptions>,
913    /// IME action button configuration.
914    pub ime_action: Option<repose_core::ImeAction>,
915}
916
917impl TextFieldEx {
918    pub fn new(hint: impl Into<String>, value: String, modifier: repose_core::Modifier) -> Self {
919        Self {
920            hint: hint.into(),
921            value,
922            modifier,
923            on_change: None,
924            on_submit: None,
925            visual_transformation: None,
926            keyboard_options: None,
927            ime_action: None,
928        }
929    }
930
931    pub fn on_change(mut self, f: impl Fn(String) + 'static) -> Self {
932        self.on_change = Some(Rc::new(f));
933        self
934    }
935
936    pub fn on_submit(mut self, f: impl Fn(String) + 'static) -> Self {
937        self.on_submit = Some(Rc::new(f));
938        self
939    }
940
941    pub fn visual_transformation(mut self, vt: impl repose_core::VisualTransformation) -> Self {
942        self.visual_transformation = Some(Rc::new(vt));
943        self
944    }
945
946    pub fn keyboard_options(mut self, opts: KeyboardOptions) -> Self {
947        self.keyboard_options = Some(opts);
948        self
949    }
950
951    pub fn ime_action(mut self, action: repose_core::ImeAction) -> Self {
952        self.ime_action = Some(action);
953        self
954    }
955
956    pub fn password(mut self) -> Self {
957        self.visual_transformation =
958            Some(Rc::new(repose_core::PasswordVisualTransformation::default()));
959        self
960    }
961
962    pub fn build(self) -> repose_core::View {
963        text_field_view(
964            self.modifier,
965            self.hint,
966            self.value,
967            false,
968            self.on_change,
969            self.on_submit,
970            self.visual_transformation,
971            self.keyboard_options.map(|o| o.keyboard_type),
972            self.ime_action,
973        )
974    }
975}
976
977/// Platform-managed view: multiline text input.
978/// - Allows '\n' insertion
979/// - Renders wrapped lines + vertical scrolling
980pub fn TextArea(
981    hint: impl Into<String>,
982    value: String,
983    modifier: repose_core::Modifier,
984    on_change: Option<impl Fn(String) + 'static>,
985    on_submit: Option<impl Fn(String) + 'static>,
986) -> repose_core::View {
987    TextAreaEx {
988        hint: hint.into(),
989        value,
990        modifier,
991        on_change: on_change.map(|f| Rc::new(f) as _),
992        on_submit: on_submit.map(|f| Rc::new(f) as _),
993        visual_transformation: None,
994        keyboard_options: None,
995        ime_action: None,
996    }
997    .build()
998}
999
1000/// Extended TextArea with optional visual transformation, keyboard options, and IME action.
1001pub struct TextAreaEx {
1002    pub hint: String,
1003    pub value: String,
1004    pub modifier: repose_core::Modifier,
1005    pub on_change: Option<Rc<dyn Fn(String)>>,
1006    pub on_submit: Option<Rc<dyn Fn(String)>>,
1007    pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1008    pub keyboard_options: Option<KeyboardOptions>,
1009    pub ime_action: Option<repose_core::ImeAction>,
1010}
1011
1012impl TextAreaEx {
1013    pub fn new(hint: impl Into<String>, value: String, modifier: repose_core::Modifier) -> Self {
1014        Self {
1015            hint: hint.into(),
1016            value,
1017            modifier,
1018            on_change: None,
1019            on_submit: None,
1020            visual_transformation: None,
1021            keyboard_options: None,
1022            ime_action: None,
1023        }
1024    }
1025
1026    pub fn on_change(mut self, f: impl Fn(String) + 'static) -> Self {
1027        self.on_change = Some(Rc::new(f));
1028        self
1029    }
1030
1031    pub fn on_submit(mut self, f: impl Fn(String) + 'static) -> Self {
1032        self.on_submit = Some(Rc::new(f));
1033        self
1034    }
1035
1036    pub fn visual_transformation(mut self, vt: impl repose_core::VisualTransformation) -> Self {
1037        self.visual_transformation = Some(Rc::new(vt));
1038        self
1039    }
1040
1041    pub fn keyboard_options(mut self, opts: KeyboardOptions) -> Self {
1042        self.keyboard_options = Some(opts);
1043        self
1044    }
1045
1046    pub fn ime_action(mut self, action: repose_core::ImeAction) -> Self {
1047        self.ime_action = Some(action);
1048        self
1049    }
1050
1051    pub fn build(self) -> repose_core::View {
1052        text_field_view(
1053            self.modifier,
1054            self.hint,
1055            self.value,
1056            true,
1057            self.on_change,
1058            self.on_submit,
1059            self.visual_transformation,
1060            self.keyboard_options.map(|o| o.keyboard_type),
1061            self.ime_action,
1062        )
1063    }
1064}
1065
1066#[derive(Clone, Debug)]
1067pub struct TextAreaLayout {
1068    pub ranges: Vec<(usize, usize)>,
1069    pub line_h_px: f32,
1070}
1071
1072pub fn layout_text_area(text: &str, font_px: f32, wrap_w_px: f32) -> TextAreaLayout {
1073    let line_h = font_px * 1.3;
1074    let (ranges, _) = repose_text::wrap_line_ranges(text, font_px, wrap_w_px.max(1.0), None, true);
1075    TextAreaLayout {
1076        ranges,
1077        line_h_px: line_h,
1078    }
1079}
1080
1081/// Return (line_index, local_byte, global_byte) for a global byte index.
1082fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
1083    if ranges.is_empty() {
1084        return (0, 0, b);
1085    }
1086    for (i, (s, e)) in ranges.iter().enumerate() {
1087        if b < *s {
1088            if i == 0 {
1089                return (0, 0, b);
1090            }
1091            let (ps, pe) = ranges[i - 1];
1092            let local = pe.saturating_sub(ps);
1093            return (i - 1, local, ps + local);
1094        }
1095        if b < *e {
1096            let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
1097            return (i, local, *s + local);
1098        }
1099        if b == *e {
1100            if let Some((ns, _ne)) = ranges.get(i + 1)
1101                && *ns == b
1102            {
1103                return (i + 1, 0, b);
1104            }
1105            let local = e.saturating_sub(*s);
1106            return (i, local, *s + local);
1107        }
1108    }
1109    let (ls, le) = ranges[ranges.len() - 1];
1110    let local = le.saturating_sub(ls);
1111    (ranges.len() - 1, local, ls + local)
1112}
1113
1114/// Compute caret (x, y) in px relative to the top-left of the inner content (not scrolled).
1115pub fn caret_xy_for_byte(
1116    text: &str,
1117    font_px: f32,
1118    wrap_w_px: f32,
1119    byte: usize,
1120) -> (f32, f32, usize) {
1121    let layout = layout_text_area(text, font_px, wrap_w_px);
1122    let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
1123    let (li, local, _) = locate_byte_in_ranges(ranges, byte);
1124    let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
1125    let line = &text[s..e];
1126    let m = measure_text(line, font_px, None);
1127    let ci = byte_to_char_index(&m, local);
1128    let x = m.positions.get(ci).copied().unwrap_or(0.0);
1129    let y = (li as f32) * line_h;
1130    (x, y, li)
1131}
1132
1133/// Given x/y (px) relative to inner content (not scrolled), return nearest grapheme boundary byte index.
1134pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
1135    let layout = layout_text_area(text, font_px, wrap_w_px);
1136    let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
1137    let li = li.min(layout.ranges.len().saturating_sub(1));
1138    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1139    let line = &text[s..e];
1140    let local = index_for_x_bytes(line, font_px, x_px.max(0.0));
1141    (s + local).min(text.len())
1142}
1143
1144/// Move caret up/down in wrapped multiline text, keeping a preferred x column.
1145pub fn move_caret_vertical(
1146    text: &str,
1147    font_px: f32,
1148    wrap_w_px: f32,
1149    cur_byte: usize,
1150    dir: i32, // -1 up, +1 down
1151    preferred_x: Option<f32>,
1152) -> (usize, f32) {
1153    let layout = layout_text_area(text, font_px, wrap_w_px);
1154    if layout.ranges.is_empty() {
1155        return (cur_byte, preferred_x.unwrap_or(0.0));
1156    }
1157    let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
1158    let px = preferred_x.unwrap_or(x);
1159    let mut nli = li as i32 + dir;
1160    nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
1161    let nli = nli as usize;
1162    let (s, e) = layout.ranges[nli];
1163    let line = &text[s..e];
1164    let local = index_for_x_bytes(line, font_px, px.max(0.0));
1165    ((s + local).min(text.len()), px)
1166}
1167
1168/// Move to start/end of current visual line.
1169pub fn line_home_end(
1170    text: &str,
1171    font_px: f32,
1172    wrap_w_px: f32,
1173    cur_byte: usize,
1174    to_end: bool,
1175) -> usize {
1176    let layout = layout_text_area(text, font_px, wrap_w_px);
1177    let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
1178    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1179    if to_end { e } else { s }
1180}
1181
1182fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
1183    if i >= s.len() {
1184        return s.len();
1185    }
1186    if s.is_char_boundary(i) {
1187        return i;
1188    }
1189    let mut j = i;
1190    while j > 0 && !s.is_char_boundary(j) {
1191        j -= 1;
1192    }
1193    j
1194}
1195
1196fn char_to_byte(s: &str, ci: usize) -> usize {
1197    if ci == 0 {
1198        0
1199    } else {
1200        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
1201    }
1202}
1203
1204/// Paint a text field into the scene. Called by layout.rs when
1205/// `modifier.text_input.is_some()`. This is the Compose-equivalent of
1206/// `TextFieldCoreModifierNode.draw()` — the engine handles painting natively
1207/// when the text_input modifier is present (no caller-side painter needed).
1208pub(crate) fn paint_text_field(
1209    scene: &mut Scene,
1210    rect: repose_core::Rect,
1211    text_input: &TextInputConfig,
1212    state: Option<&Rc<RefCell<TextFieldState>>>,
1213    is_focused: bool,
1214    clip_rounded: Option<f32>,
1215) {
1216    let pad_x = dp_to_px(TF_PADDING_X_DP);
1217    let inner = repose_core::Rect {
1218        x: rect.x + pad_x,
1219        y: rect.y + dp_to_px(8.0),
1220        w: (rect.w - 2.0 * pad_x).max(0.0),
1221        h: (rect.h - dp_to_px(16.0)).max(0.0),
1222    };
1223
1224    let font_val = dp_to_px(TF_FONT_DP) * locals::text_scale().0;
1225    let line_h = font_val * 1.3;
1226    let text_off_y = (inner.h - line_h) / 2.0;
1227
1228    scene.nodes.push(SceneNode::PushClip {
1229        rect: inner,
1230        radius: 0.0,
1231    });
1232
1233    if is_focused {
1234        let radius = clip_rounded.unwrap_or(6.0);
1235        scene.nodes.push(SceneNode::Border {
1236            rect,
1237            color: locals::theme().focus,
1238            width: dp_to_px(2.0),
1239            radius: dp_to_px(radius),
1240        });
1241    }
1242
1243    let th = locals::theme();
1244    let rendered_by_vt = |original: &str| -> String {
1245        if let Some(ref vt) = text_input.visual_transformation {
1246            vt.filter(original).text
1247        } else {
1248            original.to_string()
1249        }
1250    };
1251
1252    if let Some(state_rc) = state {
1253        let st = state_rc.borrow();
1254
1255        if !text_input.multiline {
1256            // --- Single-line ---
1257            let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
1258                rendered_by_vt(&st.text)
1259            } else {
1260                st.text.clone()
1261            };
1262            let has_vt = text_input.visual_transformation.is_some();
1263            let m = measure_text(&measure_for, font_val, None);
1264
1265            // Selection highlight
1266            if st.selection.start != st.selection.end {
1267                let start_off = if has_vt {
1268                    original_offset_to_display(&st.text, &measure_for, st.selection.start)
1269                } else {
1270                    st.selection.start
1271                };
1272                let end_off = if has_vt {
1273                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1274                } else {
1275                    st.selection.end
1276                };
1277                let sx = m
1278                    .positions
1279                    .get(byte_to_char_index(&m, start_off))
1280                    .copied()
1281                    .unwrap_or(0.0)
1282                    - st.scroll_offset;
1283                let ex = m
1284                    .positions
1285                    .get(byte_to_char_index(&m, end_off))
1286                    .copied()
1287                    .unwrap_or(sx)
1288                    - st.scroll_offset;
1289                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1290                let vis_x = sx.max(0.0);
1291                let vis_ex = ex.max(0.0);
1292                scene.nodes.push(SceneNode::Rect {
1293                    rect: repose_core::Rect {
1294                        x: inner.x + vis_x,
1295                        y: inner.y + text_off_y,
1296                        w: (vis_ex - vis_x).max(0.0),
1297                        h: line_h,
1298                    },
1299                    brush: Brush::Solid(selection),
1300                    radius: 0.0,
1301                });
1302            }
1303
1304            // Text
1305            let txt_col = if st.text.is_empty() {
1306                th.on_surface_variant
1307            } else {
1308                th.on_surface
1309            };
1310            let render_txt = if st.text.is_empty() {
1311                text_input.hint.clone()
1312            } else {
1313                rendered_by_vt(&st.text)
1314            };
1315            scene.nodes.push(SceneNode::Text {
1316                rect: repose_core::Rect {
1317                    x: inner.x - st.scroll_offset,
1318                    y: inner.y + text_off_y,
1319                    w: inner.w,
1320                    h: line_h,
1321                },
1322                text: Arc::from(render_txt),
1323                color: txt_col,
1324                size: font_val,
1325                font_family: None,
1326            });
1327
1328            // Caret
1329            if is_focused && st.selection.start == st.selection.end && st.caret_visible() {
1330                let caret_off = if has_vt {
1331                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1332                } else {
1333                    st.selection.end
1334                };
1335                let cx = m
1336                    .positions
1337                    .get(byte_to_char_index(&m, caret_off))
1338                    .copied()
1339                    .unwrap_or(0.0)
1340                    - st.scroll_offset;
1341                scene.nodes.push(SceneNode::Rect {
1342                    rect: repose_core::Rect {
1343                        x: inner.x + cx.max(0.0),
1344                        y: inner.y + text_off_y,
1345                        w: dp_to_px(1.0),
1346                        h: line_h,
1347                    },
1348                    brush: Brush::Solid(th.on_surface),
1349                    radius: 0.0,
1350                });
1351            }
1352        } else {
1353            // --- Multi-line ---
1354            let layout = layout_text_area(&st.text, font_val, inner.w.max(1.0));
1355            let lh = layout.line_h_px;
1356
1357            // Hint text (empty field)
1358            if st.text.is_empty() {
1359                scene.nodes.push(SceneNode::Text {
1360                    rect: repose_core::Rect {
1361                        x: inner.x,
1362                        y: inner.y,
1363                        w: inner.w,
1364                        h: inner.h,
1365                    },
1366                    text: Arc::from(text_input.hint.clone()),
1367                    color: th.on_surface_variant,
1368                    size: font_val,
1369                    font_family: None,
1370                });
1371            } else {
1372                let display_full = rendered_by_vt(&st.text);
1373                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1374                    let ln = display_full[s..e].to_string();
1375                    let draw_y = inner.y + (i as f32) * lh - st.scroll_offset_y;
1376                    if draw_y + lh < inner.y - 1.0 || draw_y > inner.y + inner.h + 1.0 {
1377                        continue;
1378                    }
1379                    scene.nodes.push(SceneNode::Text {
1380                        rect: repose_core::Rect {
1381                            x: inner.x,
1382                            y: draw_y,
1383                            w: inner.w,
1384                            h: lh,
1385                        },
1386                        text: Arc::<str>::from(ln),
1387                        color: th.on_surface,
1388                        size: font_val,
1389                        font_family: None,
1390                    });
1391                }
1392            }
1393
1394            // Selection (multi-line)
1395            if st.selection.start != st.selection.end {
1396                let sel_a: usize = st.selection.start.min(st.selection.end);
1397                let sel_b: usize = st.selection.start.max(st.selection.end);
1398                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1399                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1400                    let os = sel_a.max(s);
1401                    let oe = sel_b.min(e);
1402                    if os >= oe {
1403                        continue;
1404                    }
1405                    let ln = &st.text[s..e];
1406                    let m = measure_text(ln, font_val, None);
1407                    let ls = os - s;
1408                    let le = oe - s;
1409                    let sx = m
1410                        .positions
1411                        .get(byte_to_char_index(&m, ls))
1412                        .copied()
1413                        .unwrap_or(0.0);
1414                    let ex = m
1415                        .positions
1416                        .get(byte_to_char_index(&m, le))
1417                        .copied()
1418                        .unwrap_or(sx);
1419                    let draw_y = inner.y + (i as f32) * lh - st.scroll_offset_y;
1420                    scene.nodes.push(SceneNode::Rect {
1421                        rect: repose_core::Rect {
1422                            x: inner.x + sx,
1423                            y: draw_y,
1424                            w: (ex - sx).max(0.0),
1425                            h: lh,
1426                        },
1427                        brush: Brush::Solid(selection),
1428                        radius: 0.0,
1429                    });
1430                }
1431            }
1432
1433            // Caret (multi-line)
1434            if is_focused && st.selection.start == st.selection.end && st.caret_visible() {
1435                let caret = st.selection.end.min(st.text.len());
1436                let (cx, cy, _li) = caret_xy_for_byte(&st.text, font_val, inner.w.max(1.0), caret);
1437                let draw_x = inner.x + cx;
1438                let draw_y = inner.y + cy - st.scroll_offset_y;
1439                scene.nodes.push(SceneNode::Rect {
1440                    rect: repose_core::Rect {
1441                        x: draw_x,
1442                        y: draw_y,
1443                        w: dp_to_px(1.0),
1444                        h: lh,
1445                    },
1446                    brush: Brush::Solid(th.on_surface),
1447                    radius: 0.0,
1448                });
1449            }
1450        }
1451    } else {
1452        // No state yet (unfocused) — render hint or raw value
1453        if text_input.value.is_empty() {
1454            scene.nodes.push(SceneNode::Text {
1455                rect: repose_core::Rect {
1456                    x: inner.x,
1457                    y: inner.y + text_off_y,
1458                    w: inner.w,
1459                    h: line_h,
1460                },
1461                text: Arc::from(text_input.hint.clone()),
1462                color: th.on_surface_variant,
1463                size: font_val,
1464                font_family: None,
1465            });
1466        } else if text_input.multiline {
1467            let layout = layout_text_area(&text_input.value, font_val, inner.w.max(1.0));
1468            let lh = layout.line_h_px;
1469            let display_full = rendered_by_vt(&text_input.value);
1470            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1471                let ln = display_full[s..e].to_string();
1472                let draw_y = inner.y + (i as f32) * lh;
1473                if draw_y + lh < inner.y - 1.0 || draw_y > inner.y + inner.h + 1.0 {
1474                    continue;
1475                }
1476                scene.nodes.push(SceneNode::Text {
1477                    rect: repose_core::Rect {
1478                        x: inner.x,
1479                        y: draw_y,
1480                        w: inner.w,
1481                        h: lh,
1482                    },
1483                    text: Arc::<str>::from(ln),
1484                    color: th.on_surface,
1485                    size: font_val,
1486                    font_family: None,
1487                });
1488            }
1489        } else {
1490            scene.nodes.push(SceneNode::Text {
1491                rect: repose_core::Rect {
1492                    x: inner.x,
1493                    y: inner.y + text_off_y,
1494                    w: inner.w,
1495                    h: line_h,
1496                },
1497                text: Arc::from(rendered_by_vt(&text_input.value)),
1498                color: th.on_surface,
1499                size: font_val,
1500                font_family: None,
1501            });
1502        }
1503    }
1504
1505    scene.nodes.push(SceneNode::PopClip);
1506}
1507
1508/// Shared view-builder for both `TextField` and `TextArea`.
1509/// Creates the view with text_input modifier. Painting is handled natively
1510/// by layout.rs when it encounters `modifier.text_input` (Compose-aligned).
1511fn text_field_view(
1512    modifier: Modifier,
1513    hint: String,
1514    value: String,
1515    multiline: bool,
1516    on_change: Option<Rc<dyn Fn(String)>>,
1517    on_submit: Option<Rc<dyn Fn(String)>>,
1518    visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1519    keyboard_type: Option<KeyboardType>,
1520    ime_action: Option<ImeAction>,
1521) -> View {
1522    let modif = modifier.text_input(TextInputConfig {
1523        hint,
1524        multiline,
1525        on_change,
1526        on_submit,
1527        focus_tracker: None,
1528        value,
1529        visual_transformation,
1530        keyboard_type,
1531        ime_action,
1532    });
1533
1534    View::new(0, ViewKind::Box)
1535        .modifier(modif)
1536        .semantics(Semantics {
1537            role: Role::TextField,
1538            label: None,
1539            focused: false,
1540            enabled: true,
1541        })
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546    use super::*;
1547
1548    #[test]
1549    fn test_index_for_x_bytes_grapheme() {
1550        let t = "A👍🏽B";
1551        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
1552        let m = measure_text(t, font_px, None);
1553        for i in 0..m.byte_offsets.len() - 1 {
1554            let b = m.byte_offsets[i];
1555            let _ = &t[..b];
1556        }
1557    }
1558}