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::collections::HashMap;
43use std::ops::Range;
44use std::rc::Rc;
45use std::sync::Arc;
46use unicode_segmentation::UnicodeSegmentation;
47use web_time::Duration;
48use web_time::Instant;
49
50use crate::layout::mul_alpha_color;
51
52thread_local! {
53    static TEXTFIELD_STATES: RefCell<HashMap<u64, Rc<RefCell<TextFieldState>>>> = RefCell::new(HashMap::new());
54}
55
56pub fn set_textfield_state(key: u64, state: Rc<RefCell<TextFieldState>>) {
57    TEXTFIELD_STATES.with(|m| m.borrow_mut().insert(key, state));
58}
59
60pub fn get_textfield_state(key: u64) -> Option<Rc<RefCell<TextFieldState>>> {
61    TEXTFIELD_STATES.with(|m| m.borrow().get(&key).cloned())
62}
63
64pub fn ensure_caret_visible(state: &mut TextFieldState, multiline: bool) {
65    let font_px = repose_core::dp_to_px(TF_FONT_DP) * repose_core::locals::text_scale().0;
66    let wrap_width = state.inner_width;
67    if multiline {
68        let (cx, cy, _) = crate::textfield::caret_xy_for_byte(
69            &state.text,
70            font_px,
71            wrap_width,
72            state.caret_index(),
73        );
74        let iw = state.inner_width;
75        let ih = state.inner_height;
76        state.ensure_caret_visible_xy(cx, cy, iw, ih, repose_core::dp_to_px(2.0));
77    } else {
78        let caret_idx = state.caret_index();
79        let (display, caret_display_off) = if let Some(vt) = &state.visual_transformation {
80            let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
81            let tfmd = vt.filter(&annotated);
82            let off =
83                repose_core::original_offset_to_display(&state.text, tfmd.text.as_str(), caret_idx);
84            (tfmd.text.text, off)
85        } else {
86            (state.text.clone(), caret_idx)
87        };
88        let m = crate::textfield::measure_text(&display, font_px, TextMeasureConfig::default());
89        let caret_x = m.positions.get(caret_display_off).copied().unwrap_or(0.0);
90        state.ensure_caret_visible(caret_x, wrap_width, repose_core::dp_to_px(2.0));
91    }
92}
93
94/// Maximum number of undo/redo operations stored in history.
95const TEXT_UNDO_CAPACITY: usize = 100;
96
97/// Time window (ms) within which consecutive operations can be merged.
98const SNAPSHOTS_INTERVAL_MILLIS: u128 = 5000;
99
100/// Type of text edit operation.
101#[derive(Clone, Copy, Debug, PartialEq)]
102enum TextEditType {
103    Insert,
104    Delete,
105    Replace,
106}
107
108/// Direction of a deletion.
109#[derive(Clone, Copy, Debug, PartialEq)]
110enum TextDeleteType {
111    Start, // backspace: cursor moving towards start
112    End,   // delete forward: cursor moving towards end
113    Inner, // selection removed
114    NotByUser,
115}
116
117/// A single atomic text change that can be undone/redone.
118#[derive(Clone, Debug)]
119pub struct TextUndoOp {
120    /// Start point of the change in the text.
121    pub index: usize,
122    /// Text that was present before the change (being replaced/deleted).
123    pub pre_text: String,
124    /// Text that was inserted (replacing pre_text).
125    pub post_text: String,
126    /// Selection before the change.
127    pub pre_selection: Range<usize>,
128    /// Selection after the change.
129    pub post_selection: Range<usize>,
130    /// When this change was first committed.
131    pub time: Instant,
132    /// Whether this change can merge with adjacent operations.
133    pub can_merge: bool,
134}
135
136impl TextUndoOp {
137    fn edit_type(&self) -> TextEditType {
138        match (self.pre_text.is_empty(), self.post_text.is_empty()) {
139            (true, true) => unreachable!("Both pre and post text cannot be empty"),
140            (true, false) => TextEditType::Insert,
141            (false, true) => TextEditType::Delete,
142            (false, false) => TextEditType::Replace,
143        }
144    }
145
146    fn is_newline(&self) -> bool {
147        self.post_text == "\n" || self.post_text == "\r\n"
148    }
149
150    /// Try to merge `self` (earlier) with `next` (later). Returns merged op if merge is possible.
151    fn try_merge(&self, next: &TextUndoOp) -> Option<TextUndoOp> {
152        if !self.can_merge || !next.can_merge {
153            return None;
154        }
155
156        let elapsed = next.time.saturating_duration_since(self.time);
157        if elapsed.as_millis() >= SNAPSHOTS_INTERVAL_MILLIS {
158            return None;
159        }
160
161        if self.is_newline() || next.is_newline() {
162            return None;
163        }
164
165        let self_type = self.edit_type();
166        if self_type != next.edit_type() {
167            return None;
168        }
169
170        match self_type {
171            TextEditType::Insert => {
172                // Only merge if next insertion continues from the end of this one
173                if self.index + self.post_text.len() == next.index {
174                    Some(TextUndoOp {
175                        index: self.index,
176                        pre_text: String::new(),
177                        post_text: format!("{}{}", self.post_text, next.post_text),
178                        pre_selection: self.pre_selection.clone(),
179                        post_selection: next.post_selection.clone(),
180                        time: self.time,
181                        can_merge: true,
182                    })
183                } else {
184                    None
185                }
186            }
187            TextEditType::Delete => {
188                let self_del = self.deletion_type();
189                let next_del = next.deletion_type();
190                // Only merge consecutive deletions with same directionality
191                if self_del == next_del
192                    && (self_del == TextDeleteType::Start || self_del == TextDeleteType::End)
193                {
194                    if self.index == next.index + next.pre_text.len() {
195                        // This op is after next (backspace: deleting right-to-left)
196                        Some(TextUndoOp {
197                            index: next.index,
198                            pre_text: format!("{}{}", next.pre_text, self.pre_text),
199                            post_text: String::new(),
200                            pre_selection: self.pre_selection.clone(),
201                            post_selection: next.post_selection.clone(),
202                            time: self.time,
203                            can_merge: true,
204                        })
205                    } else if self.index == next.index {
206                        // Same position (delete forward: deleting left-to-right)
207                        Some(TextUndoOp {
208                            index: self.index,
209                            pre_text: format!("{}{}", self.pre_text, next.pre_text),
210                            post_text: String::new(),
211                            pre_selection: self.pre_selection.clone(),
212                            post_selection: next.post_selection.clone(),
213                            time: self.time,
214                            can_merge: true,
215                        })
216                    } else {
217                        None
218                    }
219                } else {
220                    None
221                }
222            }
223            TextEditType::Replace => None,
224        }
225    }
226
227    /// Determine the deletion direction. Only meaningful when edit_type is Delete.
228    fn deletion_type(&self) -> TextDeleteType {
229        if self.edit_type() != TextEditType::Delete {
230            return TextDeleteType::NotByUser;
231        }
232        if !self.post_selection.start == self.post_selection.end {
233            return TextDeleteType::NotByUser;
234        }
235        if self.pre_selection.start == self.pre_selection.end {
236            // Collapsed selection before delete: cursor moved
237            if self.pre_selection.start > self.post_selection.start {
238                TextDeleteType::Start // backspace
239            } else {
240                TextDeleteType::End // delete forward
241            }
242        } else if self.pre_selection.start == self.post_selection.start
243            && self.pre_selection.start == self.index
244        {
245            TextDeleteType::Inner
246        } else {
247            TextDeleteType::NotByUser
248        }
249    }
250}
251
252/// Spring physics constants for smooth scroll animation.
253const SCROLL_STIFFNESS: f32 = 300.0;
254const SCROLL_DAMPING: f32 = 30.0;
255
256/// Logical font size for TextField in dp (converted to px at measure/paint time).
257pub const TF_FONT_DP: f32 = 16.0;
258
259/// Configures the keyboard for a text field.
260#[derive(Clone, Copy, Debug)]
261pub struct KeyboardOptions {
262    pub keyboard_type: repose_core::KeyboardType,
263    pub autocorrect: bool,
264    pub capitalization: repose_core::KeyboardCapitalization,
265}
266
267impl Default for KeyboardOptions {
268    fn default() -> Self {
269        Self {
270            keyboard_type: repose_core::KeyboardType::default(),
271            autocorrect: true,
272            capitalization: repose_core::KeyboardCapitalization::default(),
273        }
274    }
275}
276/// Horizontal padding inside the TextField in dp.
277pub const TF_PADDING_X_DP: f32 = 8.0;
278
279pub struct TextMetrics {
280    /// positions[i] = advance up to the i-th grapheme (len == graphemes + 1)
281    pub positions: Vec<f32>, // px
282    /// byte_offsets[i] = byte index of the i-th grapheme (last == text.len())
283    pub byte_offsets: Vec<usize>,
284}
285
286pub struct TextMeasureConfig {
287    pub font_family: Option<&'static str>,
288    pub font_weight: u16,
289    pub font_style: u8,
290    pub letter_spacing: f32,
291    pub font_variation_settings: Option<String>,
292}
293
294impl Default for TextMeasureConfig {
295    fn default() -> Self {
296        Self {
297            font_family: None,
298            font_weight: 400,
299            font_style: 0,
300            letter_spacing: 0.0,
301            font_variation_settings: None,
302        }
303    }
304}
305
306/// Measure caret positions for a single-line textfield using shaping.
307/// `font_px` must match the px size used for rendering the text.
308/// `font_family` optionally overrides the default font (e.g. for icons).
309pub fn measure_text(text: &str, font_px: f32, config: TextMeasureConfig) -> TextMetrics {
310    let m = repose_text::metrics_for_textfield(
311        text,
312        font_px,
313        config.font_family,
314        config.font_weight,
315        config.font_style,
316        config.letter_spacing,
317        config.font_variation_settings.as_deref(),
318    );
319    TextMetrics {
320        positions: m.positions,
321        byte_offsets: m.byte_offsets,
322    }
323}
324
325pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
326    match m.byte_offsets.binary_search(&byte) {
327        Ok(i) | Err(i) => i,
328    }
329}
330
331/// Given an x position (px), return the nearest grapheme boundary byte index.
332pub fn index_for_x_bytes(
333    text: &str,
334    font_px: f32,
335    x_px: f32,
336    font_weight: u16,
337    font_style: u8,
338) -> usize {
339    let m = measure_text(
340        text,
341        font_px,
342        TextMeasureConfig {
343            font_weight,
344            font_style,
345            ..Default::default()
346        },
347    );
348
349    let mut best_i = 0usize;
350    let mut best_d = f32::INFINITY;
351    for i in 0..m.positions.len() {
352        let d = (m.positions[i] - x_px).abs();
353        if d < best_d {
354            best_d = d;
355            best_i = i;
356        }
357    }
358    m.byte_offsets[best_i]
359}
360
361/// find prev/next grapheme boundaries around a byte index
362fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
363    let mut last = 0usize;
364    for (i, _) in text.grapheme_indices(true) {
365        if i >= byte {
366            break;
367        }
368        last = i;
369    }
370    last
371}
372
373fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
374    for (i, _) in text.grapheme_indices(true) {
375        if i > byte {
376            return i;
377        }
378    }
379    text.len()
380}
381
382pub struct TextFieldState {
383    pub text: String,
384    pub selection: Range<usize>,
385    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
386    pub scroll_offset: f32,                // px (x) - current animated display value
387    pub scroll_offset_y: f32,              // px (y) for multiline - current animated display value
388    pub drag_anchor: Option<usize>,        // byte index where drag began
389    pub blink_start: Instant,              // caret blink timer
390    pub inner_width: f32,                  // px
391    pub inner_height: f32,                 // px
392    pub preferred_x_px: Option<f32>,       // for Up/Down caret movement in multiline
393    /// When a visual transformation is active, this maps offsets in the
394    /// display text back to offsets in the original text.
395    pub offset_map: Option<Box<dyn OffsetMapping>>,
396    /// The active visual transformation, set during layout.
397    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
398    /// Target horizontal scroll offset (where we're animating toward).
399    pub(crate) scroll_target: f32,
400    /// Target vertical scroll offset.
401    pub(crate) scroll_target_y: f32,
402    /// Spring velocity for horizontal scroll animation.
403    scroll_vel: f32,
404    /// Spring velocity for vertical scroll animation.
405    scroll_vel_y: f32,
406    /// Last time tick_scroll_animation was called (for dt computation).
407    last_scroll_tick: Option<Instant>,
408
409    // Undo/Redo
410    /// Stack of undo operations (most recent at end).
411    undo_stack: Vec<TextUndoOp>,
412    /// Stack of redo operations (most recent at end).
413    redo_stack: Vec<TextUndoOp>,
414    /// Staging area for the latest operation that may still merge.
415    staging_undo: Option<TextUndoOp>,
416}
417
418impl std::fmt::Debug for TextFieldState {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        f.debug_struct("TextFieldState")
421            .field("text", &self.text)
422            .field("selection", &self.selection)
423            .field("composition", &self.composition)
424            .field("scroll_offset", &self.scroll_offset)
425            .field("scroll_offset_y", &self.scroll_offset_y)
426            .field("drag_anchor", &self.drag_anchor)
427            .field("blink_start", &self.blink_start)
428            .field("inner_width", &self.inner_width)
429            .field("inner_height", &self.inner_height)
430            .field("preferred_x_px", &self.preferred_x_px)
431            .field(
432                "offset_map",
433                &self.offset_map.as_ref().map(|_| "<offset_mapping>"),
434            )
435            .field(
436                "visual_transformation",
437                &self.visual_transformation.as_ref().map(|_| "<vt>"),
438            )
439            .field("scroll_target", &self.scroll_target)
440            .field("scroll_target_y", &self.scroll_target_y)
441            .field("can_undo", &self.can_undo())
442            .field("can_redo", &self.can_redo())
443            .field("undo_count", &self.undo_stack.len())
444            .field("redo_count", &self.redo_stack.len())
445            .finish()
446    }
447}
448
449impl Default for TextFieldState {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl Clone for TextFieldState {
456    fn clone(&self) -> Self {
457        Self {
458            text: self.text.clone(),
459            selection: self.selection.clone(),
460            composition: self.composition.clone(),
461            scroll_offset: self.scroll_offset,
462            scroll_offset_y: self.scroll_offset_y,
463            drag_anchor: self.drag_anchor,
464            blink_start: self.blink_start,
465            inner_width: self.inner_width,
466            inner_height: self.inner_height,
467            preferred_x_px: self.preferred_x_px,
468            offset_map: self.offset_map.as_ref().map(|m| m.clone_box()),
469            visual_transformation: self.visual_transformation.clone(),
470            scroll_target: self.scroll_target,
471            scroll_target_y: self.scroll_target_y,
472            scroll_vel: self.scroll_vel,
473            scroll_vel_y: self.scroll_vel_y,
474            last_scroll_tick: self.last_scroll_tick,
475            undo_stack: self.undo_stack.clone(),
476            redo_stack: self.redo_stack.clone(),
477            staging_undo: self.staging_undo.clone(),
478        }
479    }
480}
481
482impl TextFieldState {
483    pub fn new() -> Self {
484        Self {
485            text: String::new(),
486            selection: 0..0,
487            composition: None,
488            scroll_offset: 0.0,
489            scroll_offset_y: 0.0,
490            drag_anchor: None,
491            blink_start: Instant::now(),
492            inner_width: 0.0,
493            inner_height: 0.0,
494            preferred_x_px: None,
495            offset_map: None,
496            visual_transformation: None,
497            scroll_target: 0.0,
498            scroll_target_y: 0.0,
499            scroll_vel: 0.0,
500            scroll_vel_y: 0.0,
501            last_scroll_tick: None,
502            undo_stack: Vec::new(),
503            redo_stack: Vec::new(),
504            staging_undo: None,
505        }
506    }
507
508    // Undo/Redo
509
510    /// Whether there is an action to undo.
511    pub fn can_undo(&self) -> bool {
512        !self.undo_stack.is_empty() || self.staging_undo.is_some()
513    }
514
515    /// Whether there is an action to redo.
516    pub fn can_redo(&self) -> bool {
517        !self.redo_stack.is_empty()
518    }
519
520    /// Revert the latest edit. Returns true if an undo was performed.
521    pub fn undo(&mut self) -> bool {
522        self.flush_undo();
523        if let Some(op) = self.undo_stack.pop() {
524            let end = (op.index + op.post_text.len()).min(self.text.len());
525            self.text.replace_range(op.index..end, &op.pre_text);
526            self.selection = op.pre_selection.clone();
527            self.redo_stack.push(op);
528            self.preferred_x_px = None;
529            self.reset_caret_blink();
530            true
531        } else {
532            false
533        }
534    }
535
536    /// Re-apply a previously undone edit. Returns true if a redo was performed.
537    pub fn redo(&mut self) -> bool {
538        if let Some(op) = self.redo_stack.pop() {
539            let end = (op.index + op.pre_text.len()).min(self.text.len());
540            self.text.replace_range(op.index..end, &op.post_text);
541            self.selection = op.post_selection.clone();
542            self.undo_stack.push(op);
543            self.preferred_x_px = None;
544            self.reset_caret_blink();
545            true
546        } else {
547            false
548        }
549    }
550
551    /// Clear all undo/redo history.
552    pub fn clear_undo_history(&mut self) {
553        self.undo_stack.clear();
554        self.redo_stack.clear();
555        self.staging_undo = None;
556    }
557
558    /// Push a [TextUndoOp] to the staging area, possibly merging with the
559    /// previous staging operation. Flushes staging to the undo stack when
560    /// merge is not possible.
561    fn record_edit(&mut self, op: TextUndoOp) {
562        if let Some(staging) = self.staging_undo.take() {
563            if let Some(merged) = staging.try_merge(&op) {
564                self.staging_undo = Some(merged);
565                return;
566            }
567            // Can't merge: flush staging to undo stack
568            self.undo_stack.push(staging);
569            self.redo_stack.clear();
570            // Enforce capacity: drop oldest entries
571            while self.undo_stack.len() + 1 > TEXT_UNDO_CAPACITY {
572                self.undo_stack.remove(0);
573            }
574        }
575        self.staging_undo = Some(op);
576    }
577
578    /// Flush the staging operation into the undo stack.
579    fn flush_undo(&mut self) {
580        if let Some(op) = self.staging_undo.take() {
581            self.undo_stack.push(op);
582            self.redo_stack.clear();
583            while self.undo_stack.len() > TEXT_UNDO_CAPACITY {
584                self.undo_stack.remove(0);
585            }
586        }
587    }
588
589    fn insert_text_impl(&mut self, text: &str, can_merge: bool) {
590        let start = self.selection.start.min(self.text.len());
591        let end = self.selection.end.min(self.text.len());
592        let pre_text = self.text[start..end].to_string();
593        let pre_selection = self.selection.clone();
594
595        self.text.replace_range(start..end, text);
596        let new_pos = start + text.len();
597        self.selection = new_pos..new_pos;
598        self.preferred_x_px = None;
599        self.reset_caret_blink();
600
601        if !pre_text.is_empty() || !text.is_empty() {
602            self.record_edit(TextUndoOp {
603                index: start,
604                pre_text,
605                post_text: text.to_string(),
606                pre_selection,
607                post_selection: self.selection.clone(),
608                time: Instant::now(),
609                can_merge,
610            });
611        }
612    }
613
614    pub fn insert_text(&mut self, text: &str) {
615        self.insert_text_impl(text, true);
616    }
617
618    /// Like `insert_text` but marks the operation as unmergeable (for cut/paste).
619    pub fn insert_text_atomic(&mut self, text: &str) {
620        self.insert_text_impl(text, false);
621    }
622
623    pub fn delete_backward(&mut self) {
624        if self.selection.start == self.selection.end {
625            let pos = self.selection.start.min(self.text.len());
626            if pos > 0 {
627                let prev = prev_grapheme_boundary(&self.text, pos);
628                let pre_text = self.text[prev..pos].to_string();
629                let pre_selection = self.selection.clone();
630                self.text.replace_range(prev..pos, "");
631                self.selection = prev..prev;
632                self.preferred_x_px = None;
633                self.reset_caret_blink();
634                self.record_edit(TextUndoOp {
635                    index: prev,
636                    pre_text,
637                    post_text: String::new(),
638                    pre_selection,
639                    post_selection: self.selection.clone(),
640                    time: Instant::now(),
641                    can_merge: true,
642                });
643            }
644        } else {
645            self.insert_text_impl("", true);
646        }
647        self.preferred_x_px = None;
648        self.reset_caret_blink();
649    }
650
651    pub fn delete_forward(&mut self) {
652        if self.selection.start == self.selection.end {
653            let pos = self.selection.start.min(self.text.len());
654            if pos < self.text.len() {
655                let next = next_grapheme_boundary(&self.text, pos);
656                let pre_text = self.text[pos..next].to_string();
657                let pre_selection = self.selection.clone();
658                self.text.replace_range(pos..next, "");
659                self.preferred_x_px = None;
660                self.reset_caret_blink();
661                self.record_edit(TextUndoOp {
662                    index: pos,
663                    pre_text,
664                    post_text: String::new(),
665                    pre_selection,
666                    post_selection: self.selection.clone(),
667                    time: Instant::now(),
668                    can_merge: true,
669                });
670            }
671        } else {
672            self.insert_text_impl("", true);
673        }
674        self.preferred_x_px = None;
675        self.reset_caret_blink();
676    }
677
678    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
679        let mut pos = self.selection.end.min(self.text.len());
680        if delta < 0 {
681            for _ in 0..delta.unsigned_abs() {
682                pos = prev_grapheme_boundary(&self.text, pos);
683            }
684        } else if delta > 0 {
685            for _ in 0..(delta as usize) {
686                pos = next_grapheme_boundary(&self.text, pos);
687            }
688        }
689        if extend_selection {
690            self.selection.end = pos;
691        } else {
692            self.selection = pos..pos;
693        }
694        self.preferred_x_px = None;
695        self.reset_caret_blink();
696    }
697
698    pub fn selected_text(&self) -> String {
699        if self.selection.start == self.selection.end {
700            String::new()
701        } else {
702            self.text[self.selection.clone()].to_string()
703        }
704    }
705
706    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
707        if text.is_empty() {
708            if let Some(range) = self.composition.take() {
709                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
710                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
711                if s <= e {
712                    self.text.replace_range(s..e, "");
713                    self.selection = s..s;
714                }
715            }
716            self.preferred_x_px = None;
717            self.reset_caret_blink();
718            return;
719        }
720
721        let anchor_start;
722        if let Some(r) = self.composition.take() {
723            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
724            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
725            if e < s {
726                std::mem::swap(&mut s, &mut e);
727            }
728            self.text.replace_range(s..e, &text);
729            anchor_start = s;
730        } else {
731            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
732            self.text.insert_str(pos, &text);
733            anchor_start = pos;
734        }
735
736        self.composition = Some(anchor_start..(anchor_start + text.len()));
737
738        if let Some((c0, c1)) = cursor {
739            let b0 = char_to_byte(&text, c0);
740            let b1 = char_to_byte(&text, c1);
741            self.selection = (anchor_start + b0)..(anchor_start + b1);
742        } else {
743            let end = anchor_start + text.len();
744            self.selection = end..end;
745        }
746
747        self.preferred_x_px = None;
748        self.reset_caret_blink();
749    }
750
751    pub fn commit_composition(&mut self, text: String) {
752        let pre_selection = self.selection.clone();
753        if let Some(r) = self.composition.take() {
754            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
755            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
756            let pre_text = self.text[s..e].to_string();
757            self.text.replace_range(s..e, &text);
758            let new_pos = s + text.len();
759            self.selection = new_pos..new_pos;
760            self.preferred_x_px = None;
761            self.reset_caret_blink();
762            if !pre_text.is_empty() || !text.is_empty() {
763                self.record_edit(TextUndoOp {
764                    index: s,
765                    pre_text,
766                    post_text: text,
767                    pre_selection,
768                    post_selection: self.selection.clone(),
769                    time: Instant::now(),
770                    can_merge: true,
771                });
772            }
773        } else {
774            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
775            self.text.insert_str(pos, &text);
776            let new_pos = pos + text.len();
777            self.selection = new_pos..new_pos;
778            self.preferred_x_px = None;
779            self.reset_caret_blink();
780            if !text.is_empty() {
781                self.record_edit(TextUndoOp {
782                    index: pos,
783                    pre_text: String::new(),
784                    post_text: text,
785                    pre_selection,
786                    post_selection: self.selection.clone(),
787                    time: Instant::now(),
788                    can_merge: true,
789                });
790            }
791        }
792    }
793
794    pub fn cancel_composition(&mut self) {
795        if let Some(r) = self.composition.take() {
796            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
797            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
798            if s <= e {
799                self.text.replace_range(s..e, "");
800                self.selection = s..s;
801            }
802        }
803        self.preferred_x_px = None;
804        self.reset_caret_blink();
805    }
806
807    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
808        if self.selection.start != self.selection.end {
809            let start = self.selection.start.min(self.text.len());
810            let end = self.selection.end.min(self.text.len());
811            self.text.replace_range(start..end, "");
812            self.selection = start..start;
813            self.preferred_x_px = None;
814            self.reset_caret_blink();
815            return;
816        }
817
818        let caret = self.selection.end.min(self.text.len());
819        let start_raw = caret.saturating_sub(before_bytes);
820        let end_raw = (caret + after_bytes).min(self.text.len());
821
822        let start = prev_grapheme_boundary(&self.text, start_raw);
823        let end = next_grapheme_boundary(&self.text, end_raw);
824        if start < end {
825            self.text.replace_range(start..end, "");
826            self.selection = start..start;
827        }
828        self.preferred_x_px = None;
829        self.reset_caret_blink();
830    }
831
832    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
833        let idx = idx_byte.min(self.text.len());
834        if extend {
835            let anchor = self.selection.start;
836            self.selection = anchor.min(idx)..anchor.max(idx);
837            self.drag_anchor = Some(anchor);
838        } else {
839            self.selection = idx..idx;
840            self.drag_anchor = Some(idx);
841        }
842        self.preferred_x_px = None;
843        self.reset_caret_blink();
844    }
845
846    pub fn drag_to(&mut self, idx_byte: usize) {
847        if let Some(anchor) = self.drag_anchor {
848            let i = idx_byte.min(self.text.len());
849            self.selection = anchor.min(i)..anchor.max(i);
850        }
851        self.preferred_x_px = None;
852        self.reset_caret_blink();
853    }
854    pub fn end_drag(&mut self) {
855        self.drag_anchor = None;
856    }
857
858    pub fn caret_index(&self) -> usize {
859        self.selection.end
860    }
861
862    /// Keep caret visible inside inner content width (px).
863    /// `inset_px` is a small padding (px) to avoid hugging edges.
864    /// Sets the scroll target for smooth animated scrolling.
865    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
866        self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
867    }
868
869    /// Keep caret visible inside an inner rect (for multiline).
870    /// Sets the scroll target for smooth animated scrolling.
871    pub fn ensure_caret_visible_xy(
872        &mut self,
873        caret_x_px: f32,
874        caret_y_px: f32,
875        inner_w_px: f32,
876        inner_h_px: f32,
877        inset_px: f32,
878    ) {
879        let inset_px = inset_px.max(0.0);
880
881        // Compute target X scroll based on current display offset
882        let left_px = self.scroll_offset + inset_px;
883        let right_px = self.scroll_offset + inner_w_px - inset_px;
884        if caret_x_px < left_px {
885            self.scroll_target = (caret_x_px - inset_px).max(0.0);
886        } else if caret_x_px > right_px {
887            self.scroll_target = (caret_x_px - inner_w_px + inset_px).max(0.0);
888        }
889
890        // Compute target Y scroll based on current display offset
891        let top_px = self.scroll_offset_y + inset_px;
892        let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
893        if caret_y_px < top_px {
894            self.scroll_target_y = (caret_y_px - inset_px).max(0.0);
895        } else if caret_y_px > bot_px {
896            self.scroll_target_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
897        }
898    }
899
900    pub fn clamp_scroll(&mut self, content_h_px: f32) {
901        let max_y = (content_h_px - self.inner_height).max(0.0);
902        self.scroll_target_y = self.scroll_target_y.clamp(0.0, max_y);
903        if self.scroll_target_y.is_nan() {
904            self.scroll_target_y = 0.0;
905        }
906    }
907
908    pub fn reset_caret_blink(&mut self) {
909        self.blink_start = Instant::now();
910    }
911    pub fn caret_visible(&self) -> bool {
912        const PERIOD: Duration = Duration::from_millis(500);
913        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
914    }
915
916    pub fn set_inner_width(&mut self, w_px: f32) {
917        self.inner_width = w_px.max(0.0);
918        if self.scroll_offset.is_nan() {
919            self.scroll_offset = 0.0;
920        }
921        if self.scroll_target.is_nan() {
922            self.scroll_target = 0.0;
923        }
924    }
925    pub fn set_inner_height(&mut self, h_px: f32) {
926        self.inner_height = h_px.max(0.0);
927        if self.scroll_offset_y.is_nan() {
928            self.scroll_offset_y = 0.0;
929        }
930        if self.scroll_target_y.is_nan() {
931            self.scroll_target_y = 0.0;
932        }
933    }
934
935    /// Advance scroll animation by actual wall-clock dt using spring physics.
936    /// Call this once per frame before reading [scroll_offset] / [scroll_offset_y].
937    /// On the first call after a target change, snaps immediately to avoid 1-frame delay.
938    pub fn tick_scroll_animation(&mut self) {
939        let now = Instant::now();
940        let dt = match self.last_scroll_tick {
941            Some(prev) => {
942                let d = now.saturating_duration_since(prev).as_secs_f32();
943                d.min(0.05) // cap to 50ms to avoid jumps after pause
944            }
945            None => {
946                // First tick: snap to target immediately, but record the time
947                // so subsequent ticks produce a smooth spring.
948                self.last_scroll_tick = Some(now);
949                self.scroll_offset = self.scroll_target;
950                self.scroll_vel = 0.0;
951                self.scroll_offset_y = self.scroll_target_y;
952                self.scroll_vel_y = 0.0;
953                return;
954            }
955        };
956        self.last_scroll_tick = Some(now);
957
958        // X axis
959        if dt > 0.0 {
960            let dx = self.scroll_target - self.scroll_offset;
961            let near_x = dx.abs() < 0.5 && self.scroll_vel.abs() < 0.5;
962            if near_x {
963                self.scroll_offset = self.scroll_target;
964                self.scroll_vel = 0.0;
965            } else {
966                let force_x = SCROLL_STIFFNESS * dx - SCROLL_DAMPING * self.scroll_vel;
967                self.scroll_vel += force_x * dt;
968                self.scroll_offset += self.scroll_vel * dt;
969                // Overshoot protection: clamp to target if we'd pass it this frame
970                if (self.scroll_target - self.scroll_offset).signum() != dx.signum() && dx != 0.0 {
971                    self.scroll_offset = self.scroll_target;
972                    self.scroll_vel = 0.0;
973                }
974            }
975        }
976
977        // Y axis
978        if dt > 0.0 {
979            let dy = self.scroll_target_y - self.scroll_offset_y;
980            let near_y = dy.abs() < 0.5 && self.scroll_vel_y.abs() < 0.5;
981            if near_y {
982                self.scroll_offset_y = self.scroll_target_y;
983                self.scroll_vel_y = 0.0;
984            } else {
985                let force_y = SCROLL_STIFFNESS * dy - SCROLL_DAMPING * self.scroll_vel_y;
986                self.scroll_vel_y += force_y * dt;
987                self.scroll_offset_y += self.scroll_vel_y * dt;
988                if (self.scroll_target_y - self.scroll_offset_y).signum() != dy.signum()
989                    && dy != 0.0
990                {
991                    self.scroll_offset_y = self.scroll_target_y;
992                    self.scroll_vel_y = 0.0;
993                }
994            }
995        }
996    }
997}
998
999/// Configuration for `BasicTextField` / `BasicSecureTextField`.
1000///
1001/// Use `..Default::default()` for unset fields:
1002/// ```ignore
1003/// BasicTextField(state, modifier, "Hint", TextFieldConfig {
1004///     enabled: false,
1005///     ..Default::default()
1006/// })
1007/// ```
1008#[derive(Clone)]
1009pub struct TextFieldConfig {
1010    /// When false, the text field is not editable, not focusable, and input is not selectable (-> `enabled`).
1011    pub enabled: bool,
1012    /// When true, the text field can be focused and text can be selected/copied, but not modified (-> `readOnly`).
1013    pub read_only: bool,
1014    /// Input transformation (-> `inputTransformation`). Transforms text before it is applied.
1015    pub input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
1016    /// Style for the text content (-> `textStyle`).
1017    pub text_style: repose_core::TextStyle,
1018    /// Platform keyboard configuration hints (-> `keyboardOptions`).
1019    pub keyboard_options: repose_core::KeyboardOptions,
1020    /// Per-action IME callback (-> `onKeyboardAction`).
1021    pub on_keyboard_action: Option<Rc<dyn repose_core::KeyboardActionHandler>>,
1022    /// Line limits (-> `TextFieldLineLimits`).
1023    pub line_limits: repose_core::TextFieldLineLimits,
1024    /// Callback invoked after each text layout computation (-> `onTextLayout`).
1025    pub on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
1026    /// Interaction source for tracking focus/press/hover state.
1027    pub interaction_source: Option<repose_core::MutableInteractionSource>,
1028    /// Cursor brush (-> `cursorBrush`). `None` → theme default (`on_surface`).
1029    pub cursor_brush: Option<repose_core::Brush>,
1030    /// Output transformation (-> `outputTransformation`). Transforms text for display only.
1031    pub output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
1032    /// Decorator (-> `decorator`). Wraps the inner text field with custom decorations.
1033    pub decorator: Option<Rc<dyn repose_core::TextFieldDecorator>>,
1034    /// Internal codepoint transformation for password obfuscation (-> `codepointTransformation`).
1035    pub codepoint_transformation: Option<repose_core::CodepointTransformation>,
1036    /// Text obfuscation mode (-> `textObfuscationMode`). Used by `BasicSecureTextField`.
1037    pub text_obfuscation_mode: repose_core::TextObfuscationMode,
1038    /// Character used for text obfuscation (-> `textObfuscationCharacter`). Used by `BasicSecureTextField`.
1039    pub text_obfuscation_character: char,
1040
1041    // Legacy / reposé-specific (for migration convenience, kept in config)
1042    pub on_change: Option<Rc<dyn Fn(String)>>,
1043    pub on_submit: Option<Rc<dyn Fn(String)>>,
1044    pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1045    pub decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
1046}
1047
1048impl Default for TextFieldConfig {
1049    fn default() -> Self {
1050        Self {
1051            enabled: true,
1052            read_only: false,
1053            input_transformation: None,
1054            text_style: Default::default(),
1055            keyboard_options: repose_core::KeyboardOptions::DEFAULT.clone(),
1056            on_keyboard_action: None,
1057            line_limits: repose_core::TextFieldLineLimits::MultiLine {
1058                min_height_in_lines: 1,
1059                max_height_in_lines: usize::MAX,
1060            },
1061            on_text_layout: None,
1062            interaction_source: None,
1063            cursor_brush: None,
1064            output_transformation: None,
1065            decorator: None,
1066            codepoint_transformation: None,
1067            text_obfuscation_mode: repose_core::TextObfuscationMode::System,
1068            text_obfuscation_character: '\u{2022}',
1069            on_change: None,
1070            on_submit: None,
1071            visual_transformation: None,
1072            decoration_box: None,
1073        }
1074    }
1075}
1076
1077/// State-based text field. Corresponds to Compose's `BasicTextField(state: TextFieldState, ...)`.
1078///
1079/// The state is managed externally and all editing is reflected in the `TextFieldState`
1080/// object passed to the platform runner via `set_textfield_state`.
1081///
1082/// # Example
1083/// ```ignore
1084/// let state = Rc::new(RefCell::new(TextFieldState::new("")));
1085/// BasicTextField(state.clone(), Modifier::new(), "Hint", TextFieldConfig {
1086///     enabled: false,
1087///     ..Default::default()
1088/// })
1089/// ```
1090pub fn BasicTextField(
1091    state: Rc<RefCell<TextFieldState>>,
1092    modifier: repose_core::Modifier,
1093    hint: impl Into<String>,
1094    config: TextFieldConfig,
1095) -> repose_core::View {
1096    let (single_line, max_lines, min_lines) = match config.line_limits {
1097        repose_core::TextFieldLineLimits::SingleLine => (true, 1, 1),
1098        repose_core::TextFieldLineLimits::MultiLine {
1099            min_height_in_lines,
1100            max_height_in_lines,
1101        } => (false, max_height_in_lines, min_height_in_lines),
1102    };
1103
1104    let ka = if let Some(ref handler) = config.on_keyboard_action {
1105        let handler = handler.clone();
1106        repose_core::KeyboardActions {
1107            on_done: Some({
1108                let h = handler.clone();
1109                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1110                    h.on_keyboard_action(&|| {})
1111                })
1112            }),
1113            on_go: Some({
1114                let h = handler.clone();
1115                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1116                    h.on_keyboard_action(&|| {})
1117                })
1118            }),
1119            on_next: Some({
1120                let h = handler.clone();
1121                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1122                    h.on_keyboard_action(&|| {})
1123                })
1124            }),
1125            on_previous: Some({
1126                let h = handler.clone();
1127                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1128                    h.on_keyboard_action(&|| {})
1129                })
1130            }),
1131            on_search: Some({
1132                let h = handler.clone();
1133                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1134                    h.on_keyboard_action(&|| {})
1135                })
1136            }),
1137            on_send: Some({
1138                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1139                    handler.on_keyboard_action(&|| {})
1140                })
1141            }),
1142        }
1143    } else {
1144        repose_core::KeyboardActions::default()
1145    };
1146
1147    let decoration_box = config
1148        .decorator
1149        .map(|d| Rc::new(move |inner: repose_core::View| d.decorate(inner)) as Rc<dyn Fn(_) -> _>);
1150
1151    let cursor_color = config.cursor_brush.and_then(|b| match b {
1152        repose_core::Brush::Solid(c) => Some(c),
1153        _ => None,
1154    });
1155
1156    let value = state.borrow().text.clone();
1157    let key = state.as_ptr() as u64;
1158    set_textfield_state(key, state.clone());
1159
1160    let state_on_change = {
1161        let s = state.clone();
1162        move |new_value: String| {
1163            s.borrow_mut().text = new_value;
1164        }
1165    };
1166
1167    let merged_on_change: Option<Rc<dyn Fn(String)>> =
1168        if let Some(ref cfg_on_change) = config.on_change {
1169            let a = Rc::new(state_on_change) as Rc<dyn Fn(String)>;
1170            let b = cfg_on_change.clone();
1171            Some(Rc::new(move |v: String| {
1172                a(v.clone());
1173                b(v);
1174            }) as Rc<dyn Fn(String)>)
1175        } else {
1176            Some(Rc::new(state_on_change) as Rc<dyn Fn(String)>)
1177        };
1178
1179    text_field_view(
1180        modifier,
1181        hint.into(),
1182        value,
1183        !single_line,
1184        merged_on_change,
1185        config.on_submit,
1186        config.visual_transformation,
1187        config.keyboard_options.keyboard_type,
1188        config.keyboard_options.capitalization,
1189        config.keyboard_options.ime_action,
1190        config.enabled,
1191        config.read_only,
1192        Some(max_lines),
1193        min_lines,
1194        cursor_color,
1195        config.on_text_layout,
1196        config.text_style,
1197        ka,
1198        config.interaction_source,
1199        Some(config.line_limits),
1200        config.input_transformation,
1201        config.output_transformation,
1202        decoration_box,
1203        config.codepoint_transformation,
1204    )
1205}
1206
1207/// Secure text field for password entry. Corresponds to Compose's `BasicSecureTextField`.
1208///
1209/// Wraps `BasicTextField` with secure defaults: single-line, password keyboard,
1210/// text obfuscation, and disabled cut/copy.
1211pub fn BasicSecureTextField(
1212    state: Rc<RefCell<TextFieldState>>,
1213    modifier: repose_core::Modifier,
1214    config: TextFieldConfig,
1215) -> repose_core::View {
1216    let mask = config.text_obfuscation_character;
1217    let secure_config = TextFieldConfig {
1218        line_limits: repose_core::TextFieldLineLimits::SingleLine,
1219        keyboard_options: repose_core::KeyboardOptions::SECURE_TEXT_FIELD,
1220        visual_transformation: match config.text_obfuscation_mode {
1221            repose_core::TextObfuscationMode::Visible => None,
1222            _ => Some(Rc::new(repose_core::PasswordVisualTransformation { mask })
1223                as Rc<dyn repose_core::VisualTransformation>),
1224        },
1225        ..config
1226    };
1227    BasicTextField(state, modifier, "", secure_config)
1228}
1229
1230#[derive(Clone, Debug)]
1231pub struct TextAreaLayout {
1232    pub ranges: Vec<(usize, usize)>,
1233    pub line_h_px: f32,
1234}
1235
1236pub fn layout_text_area(
1237    text: &str,
1238    font_px: f32,
1239    wrap_w_px: f32,
1240    font_weight: u16,
1241    font_style: u8,
1242    letter_spacing: f32,
1243    font_variation_settings: Option<&str>,
1244) -> TextAreaLayout {
1245    let line_h = font_px;
1246    let (ranges, _) = repose_text::wrap_line_ranges(
1247        text,
1248        font_px,
1249        wrap_w_px.max(1.0),
1250        None,
1251        true,
1252        font_weight,
1253        font_style,
1254        letter_spacing,
1255        font_variation_settings,
1256    );
1257    TextAreaLayout {
1258        ranges,
1259        line_h_px: line_h,
1260    }
1261}
1262
1263/// Return (line_index, local_byte, global_byte) for a global byte index.
1264fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
1265    if ranges.is_empty() {
1266        return (0, 0, b);
1267    }
1268    for (i, (s, e)) in ranges.iter().enumerate() {
1269        if b < *s {
1270            if i == 0 {
1271                return (0, 0, b);
1272            }
1273            let (ps, pe) = ranges[i - 1];
1274            let local = pe.saturating_sub(ps);
1275            return (i - 1, local, ps + local);
1276        }
1277        if b < *e {
1278            let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
1279            return (i, local, *s + local);
1280        }
1281        if b == *e {
1282            if let Some((ns, _ne)) = ranges.get(i + 1)
1283                && *ns == b
1284            {
1285                return (i + 1, 0, b);
1286            }
1287            let local = e.saturating_sub(*s);
1288            return (i, local, *s + local);
1289        }
1290    }
1291    let (ls, le) = ranges[ranges.len() - 1];
1292    let local = le.saturating_sub(ls);
1293    (ranges.len() - 1, local, ls + local)
1294}
1295
1296/// Compute caret (x, y) in px relative to the top-left of the inner content (not scrolled).
1297pub fn caret_xy_for_byte(
1298    text: &str,
1299    font_px: f32,
1300    wrap_w_px: f32,
1301    byte: usize,
1302) -> (f32, f32, usize) {
1303    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1304    let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
1305    let (li, local, _) = locate_byte_in_ranges(ranges, byte);
1306    let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
1307    let line = &text[s..e];
1308    let m = measure_text(line, font_px, TextMeasureConfig::default());
1309    let ci = byte_to_char_index(&m, local);
1310    let x = m.positions.get(ci).copied().unwrap_or(0.0);
1311    let y = (li as f32) * line_h;
1312    (x, y, li)
1313}
1314
1315/// Given x/y (px) relative to inner content (not scrolled), return nearest grapheme boundary byte index.
1316pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
1317    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1318    let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
1319    let li = li.min(layout.ranges.len().saturating_sub(1));
1320    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1321    let line = &text[s..e];
1322    let local = index_for_x_bytes(line, font_px, x_px.max(0.0), 400, 0);
1323    (s + local).min(text.len())
1324}
1325
1326/// Move caret up/down in wrapped multiline text, keeping a preferred x column.
1327pub fn move_caret_vertical(
1328    text: &str,
1329    font_px: f32,
1330    wrap_w_px: f32,
1331    cur_byte: usize,
1332    dir: i32, // -1 up, +1 down
1333    preferred_x: Option<f32>,
1334) -> (usize, f32) {
1335    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1336    if layout.ranges.is_empty() {
1337        return (cur_byte, preferred_x.unwrap_or(0.0));
1338    }
1339    let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
1340    let px = preferred_x.unwrap_or(x);
1341    let mut nli = li as i32 + dir;
1342    nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
1343    let nli = nli as usize;
1344    let (s, e) = layout.ranges[nli];
1345    let line = &text[s..e];
1346    let local = index_for_x_bytes(line, font_px, px.max(0.0), 400, 0);
1347    ((s + local).min(text.len()), px)
1348}
1349
1350/// Move to start/end of current visual line.
1351pub fn line_home_end(
1352    text: &str,
1353    font_px: f32,
1354    wrap_w_px: f32,
1355    cur_byte: usize,
1356    to_end: bool,
1357) -> usize {
1358    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1359    let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
1360    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1361    if to_end { e } else { s }
1362}
1363
1364fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
1365    if i >= s.len() {
1366        return s.len();
1367    }
1368    if s.is_char_boundary(i) {
1369        return i;
1370    }
1371    let mut j = i;
1372    while j > 0 && !s.is_char_boundary(j) {
1373        j -= 1;
1374    }
1375    j
1376}
1377
1378fn char_to_byte(s: &str, ci: usize) -> usize {
1379    if ci == 0 {
1380        0
1381    } else {
1382        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
1383    }
1384}
1385
1386/// Paint a text field into the scene. Called by layout.rs when
1387/// `modifier.text_input.is_some()`. This is the Compose-equivalent of
1388/// `TextFieldCoreModifierNode.draw()` - the engine handles painting natively
1389/// when the text_input modifier is present (no caller-side painter needed).
1390///
1391/// Behavior per Compose BasicTextField:
1392/// - `text_input.enabled=false`: no cursor, no selection highlight, text rendered normally
1393/// - `text_input.read_only=true`: no cursor, selection highlight rendered
1394/// - `cursor_color`: overrides cursor brush
1395/// - `max_lines`: caps rendered lines (clip applied by container)
1396/// - `on_text_layout`: called after layout computation
1397pub(crate) fn paint_text_field(
1398    scene: &mut Scene,
1399    rect: repose_core::Rect,
1400    text_input: &TextInputConfig,
1401    state: Option<&Rc<RefCell<TextFieldState>>>,
1402    is_focused: bool,
1403    clip_rounded: Option<[f32; 4]>,
1404    alpha_accum: f32,
1405) {
1406    let ts = text_input
1407        .text_style
1408        .as_ref()
1409        .map(|s| s.clone())
1410        .unwrap_or_default();
1411    let font_size_dp = if ts.font_size != 0.0 {
1412        ts.font_size
1413    } else {
1414        TF_FONT_DP
1415    };
1416    let font_val = dp_to_px(font_size_dp) * locals::text_scale().0;
1417    let line_h = if ts.line_height != 0.0 {
1418        dp_to_px(ts.line_height) * locals::text_scale().0
1419    } else if text_input.multiline {
1420        0.0 // sentinel → renderer uses Normal line height (font-metric-based)
1421    } else {
1422        font_val // single-line needs tp use font em-size for correct cursor–text alignment
1423    };
1424    let text_off_y = (rect.h - line_h.max(font_val)) / 2.0;
1425
1426    let clip_radius = clip_rounded.unwrap_or([0.0; 4]).map(dp_to_px);
1427    scene.nodes.push(SceneNode::PushClip {
1428        rect,
1429        radius: clip_radius,
1430        op: repose_core::ClipOp::Intersect,
1431    });
1432
1433    let th = locals::theme();
1434    let show_selection = text_input.enabled;
1435    let show_cursor = text_input.enabled && !text_input.read_only;
1436    let cursor_color = text_input.cursor_color.unwrap_or(th.on_surface);
1437    let rendered_by_vt = |original: &str| -> String {
1438        if let Some(ref vt) = text_input.visual_transformation {
1439            let annotated = repose_core::AnnotatedString::new(original.to_string(), vec![]);
1440            vt.filter(&annotated).text.text
1441        } else {
1442            original.to_string()
1443        }
1444    };
1445
1446    if let Some(state_rc) = state {
1447        let st = state_rc.borrow();
1448
1449        if !text_input.multiline {
1450            // Single-line
1451            let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
1452                rendered_by_vt(&st.text)
1453            } else {
1454                st.text.clone()
1455            };
1456            let has_vt = text_input.visual_transformation.is_some();
1457            let m = measure_text(
1458                &measure_for,
1459                font_val,
1460                TextMeasureConfig {
1461                    font_family: ts.font_family,
1462                    font_weight: ts.font_weight.unwrap_or(400),
1463                    font_style: ts.font_style.unwrap_or(0),
1464                    letter_spacing: ts.letter_spacing,
1465                    font_variation_settings: None,
1466                },
1467            );
1468
1469            // Selection highlight
1470            if show_selection && st.selection.start != st.selection.end {
1471                let start_off = if has_vt {
1472                    original_offset_to_display(&st.text, &measure_for, st.selection.start)
1473                } else {
1474                    st.selection.start
1475                };
1476                let end_off = if has_vt {
1477                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1478                } else {
1479                    st.selection.end
1480                };
1481                let sx = m
1482                    .positions
1483                    .get(byte_to_char_index(&m, start_off))
1484                    .copied()
1485                    .unwrap_or(0.0)
1486                    - st.scroll_offset;
1487                let ex = m
1488                    .positions
1489                    .get(byte_to_char_index(&m, end_off))
1490                    .copied()
1491                    .unwrap_or(sx)
1492                    - st.scroll_offset;
1493                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1494                let vis_x = sx.max(0.0);
1495                let vis_ex = ex.max(0.0);
1496                scene.nodes.push(SceneNode::Rect {
1497                    rect: repose_core::Rect {
1498                        x: rect.x + vis_x,
1499                        y: rect.y + text_off_y,
1500                        w: (vis_ex - vis_x).max(0.0),
1501                        h: line_h.max(font_val),
1502                    },
1503                    brush: Brush::Solid(selection),
1504                    radius: [0.0; 4],
1505                });
1506            }
1507
1508            // Text
1509            let txt_col = if st.text.is_empty() {
1510                ts.color.unwrap_or(th.on_surface_variant)
1511            } else {
1512                ts.color.unwrap_or(th.on_surface)
1513            };
1514            let render_txt = if st.text.is_empty() {
1515                text_input.hint.clone()
1516            } else {
1517                rendered_by_vt(&st.text)
1518            };
1519            scene.nodes.push(SceneNode::Text {
1520                rect: repose_core::Rect {
1521                    x: rect.x - st.scroll_offset,
1522                    y: rect.y + text_off_y,
1523                    w: rect.w,
1524                    h: line_h,
1525                },
1526                text: Arc::from(render_txt),
1527                color: mul_alpha_color(txt_col, alpha_accum),
1528                size: font_val,
1529                font_family: ts.font_family,
1530                text_align: ts.text_align,
1531                font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1532                font_style: match ts.font_style.unwrap_or(0) {
1533                    1 => FontStyle::Italic,
1534                    _ => FontStyle::Normal,
1535                },
1536                text_decoration: ts.text_decoration.unwrap_or_default(),
1537                letter_spacing: ts.letter_spacing,
1538                line_height: ts.line_height,
1539                extra_style: Default::default(),
1540                url: None,
1541                font_variation_settings: None,
1542            });
1543
1544            // Caret (only when enabled && !readOnly)
1545            if show_cursor
1546                && is_focused
1547                && st.selection.start == st.selection.end
1548                && st.caret_visible()
1549            {
1550                let caret_off = if has_vt {
1551                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1552                } else {
1553                    st.selection.end
1554                };
1555                let cx = m
1556                    .positions
1557                    .get(byte_to_char_index(&m, caret_off))
1558                    .copied()
1559                    .unwrap_or(0.0)
1560                    - st.scroll_offset;
1561                let cursor_y = rect.y + text_off_y + (line_h.max(font_val) - font_val) / 2.0;
1562                scene.nodes.push(SceneNode::Rect {
1563                    rect: repose_core::Rect {
1564                        x: rect.x + cx.max(0.0),
1565                        y: cursor_y,
1566                        w: dp_to_px(1.0),
1567                        h: font_val,
1568                    },
1569                    brush: Brush::Solid(cursor_color),
1570                    radius: [0.0; 4],
1571                });
1572            }
1573        } else {
1574            // Multi-line
1575            let render_text = if st.text.is_empty() {
1576                st.text.clone()
1577            } else if let Some(ref vt) = text_input.visual_transformation {
1578                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
1579                vt.filter(&annotated).text.text
1580            } else {
1581                st.text.clone()
1582            };
1583            let layout = layout_text_area(
1584                &render_text,
1585                font_val,
1586                rect.w.max(1.0),
1587                400,
1588                0,
1589                ts.letter_spacing,
1590                None,
1591            );
1592            let lh = layout.line_h_px;
1593            let max_line_count = text_input.max_lines.unwrap_or(usize::MAX);
1594
1595            // Hint text (empty field)
1596            if st.text.is_empty() {
1597                scene.nodes.push(SceneNode::Text {
1598                    rect: repose_core::Rect {
1599                        x: rect.x,
1600                        y: rect.y,
1601                        w: rect.w,
1602                        h: line_h,
1603                    },
1604                    text: Arc::from(text_input.hint.clone()),
1605                    color: mul_alpha_color(ts.color.unwrap_or(th.on_surface_variant), alpha_accum),
1606                    size: font_val,
1607                    font_family: ts.font_family,
1608                    text_align: ts.text_align,
1609                    font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1610                    font_style: match ts.font_style.unwrap_or(0) {
1611                        1 => FontStyle::Italic,
1612                        _ => FontStyle::Normal,
1613                    },
1614                    text_decoration: ts.text_decoration.unwrap_or_default(),
1615                    letter_spacing: ts.letter_spacing,
1616                    line_height: ts.line_height,
1617                    extra_style: Default::default(),
1618                    url: None,
1619                    font_variation_settings: None,
1620                });
1621            } else {
1622                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1623                    if i >= max_line_count {
1624                        break;
1625                    }
1626                    let ln = render_text[s..e].to_string();
1627                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
1628                    if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
1629                        continue;
1630                    }
1631                    scene.nodes.push(SceneNode::Text {
1632                        rect: repose_core::Rect {
1633                            x: rect.x,
1634                            y: draw_y,
1635                            w: rect.w,
1636                            h: lh,
1637                        },
1638                        text: Arc::<str>::from(ln),
1639                        color: mul_alpha_color(ts.color.unwrap_or(th.on_surface), alpha_accum),
1640                        size: font_val,
1641                        font_family: ts.font_family,
1642                        text_align: ts.text_align,
1643                        font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1644                        font_style: match ts.font_style.unwrap_or(0) {
1645                            1 => FontStyle::Italic,
1646                            _ => FontStyle::Normal,
1647                        },
1648                        text_decoration: ts.text_decoration.unwrap_or_default(),
1649                        letter_spacing: ts.letter_spacing,
1650                        line_height: ts.line_height,
1651                        extra_style: Default::default(),
1652                        url: None,
1653                        font_variation_settings: None,
1654                    });
1655                }
1656            }
1657
1658            // Selection (multi-line)
1659            if show_selection && st.selection.start != st.selection.end {
1660                let sel_a_orig: usize = st.selection.start.min(st.selection.end);
1661                let sel_b_orig: usize = st.selection.start.max(st.selection.end);
1662                let has_vt = text_input.visual_transformation.is_some();
1663                let sel_a = if has_vt {
1664                    original_offset_to_display(&st.text, &render_text, sel_a_orig)
1665                } else {
1666                    sel_a_orig
1667                };
1668                let sel_b = if has_vt {
1669                    original_offset_to_display(&st.text, &render_text, sel_b_orig)
1670                } else {
1671                    sel_b_orig
1672                };
1673                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1674                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1675                    if i >= max_line_count {
1676                        break;
1677                    }
1678                    let os = sel_a.max(s);
1679                    let oe = sel_b.min(e);
1680                    if os >= oe {
1681                        continue;
1682                    }
1683                    let ln = &render_text[s..e];
1684                    let m = measure_text(
1685                        ln,
1686                        font_val,
1687                        TextMeasureConfig {
1688                            font_family: ts.font_family,
1689                            font_weight: ts.font_weight.unwrap_or(400),
1690                            font_style: ts.font_style.unwrap_or(0),
1691                            letter_spacing: ts.letter_spacing,
1692                            font_variation_settings: None,
1693                        },
1694                    );
1695                    let ls = os - s;
1696                    let le = oe - s;
1697                    let sx = m
1698                        .positions
1699                        .get(byte_to_char_index(&m, ls))
1700                        .copied()
1701                        .unwrap_or(0.0);
1702                    let ex = m
1703                        .positions
1704                        .get(byte_to_char_index(&m, le))
1705                        .copied()
1706                        .unwrap_or(sx);
1707                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
1708                    scene.nodes.push(SceneNode::Rect {
1709                        rect: repose_core::Rect {
1710                            x: rect.x + sx,
1711                            y: draw_y,
1712                            w: (ex - sx).max(0.0),
1713                            h: lh,
1714                        },
1715                        brush: Brush::Solid(selection),
1716                        radius: [0.0; 4],
1717                    });
1718                }
1719            }
1720
1721            // Caret (multi-line) - only when enabled && !readOnly
1722            if show_cursor
1723                && is_focused
1724                && st.selection.start == st.selection.end
1725                && st.caret_visible()
1726            {
1727                let caret_orig = st.selection.end.min(st.text.len());
1728                let has_vt = text_input.visual_transformation.is_some();
1729                let caret = if has_vt {
1730                    original_offset_to_display(&st.text, &render_text, caret_orig)
1731                } else {
1732                    caret_orig
1733                };
1734                let (cx, cy, _li) =
1735                    caret_xy_for_byte(&render_text, font_val, rect.w.max(1.0), caret);
1736                let draw_x = rect.x + cx;
1737                let draw_y = rect.y + cy - st.scroll_offset_y;
1738                scene.nodes.push(SceneNode::Rect {
1739                    rect: repose_core::Rect {
1740                        x: draw_x,
1741                        y: draw_y + (lh - font_val) / 2.0,
1742                        w: dp_to_px(1.0),
1743                        h: font_val,
1744                    },
1745                    brush: Brush::Solid(cursor_color),
1746                    radius: [0.0; 4],
1747                });
1748            }
1749        }
1750    } else {
1751        // No state yet (unfocused) - render hint or raw value
1752        if text_input.value.is_empty() {
1753            let hint_y = if text_input.multiline {
1754                rect.y
1755            } else {
1756                rect.y + text_off_y
1757            };
1758            scene.nodes.push(SceneNode::Text {
1759                rect: repose_core::Rect {
1760                    x: rect.x,
1761                    y: hint_y,
1762                    w: rect.w,
1763                    h: line_h,
1764                },
1765                text: Arc::from(text_input.hint.clone()),
1766                color: mul_alpha_color(th.on_surface_variant, alpha_accum),
1767                size: font_val,
1768                font_family: None,
1769                text_align: TextAlign::Unspecified,
1770                font_weight: FontWeight::NORMAL,
1771                font_style: FontStyle::Normal,
1772                text_decoration: ts.text_decoration.unwrap_or_default(),
1773                letter_spacing: 0.0,
1774                line_height: 0.0,
1775                extra_style: Default::default(),
1776                url: None,
1777                font_variation_settings: None,
1778            });
1779        } else if text_input.multiline {
1780            let render_text = if text_input.value.is_empty() {
1781                text_input.value.clone()
1782            } else if let Some(ref vt) = text_input.visual_transformation {
1783                let annotated = repose_core::AnnotatedString::new(text_input.value.clone(), vec![]);
1784                vt.filter(&annotated).text.text
1785            } else {
1786                text_input.value.clone()
1787            };
1788            let layout = layout_text_area(
1789                &render_text,
1790                font_val,
1791                rect.w.max(1.0),
1792                400,
1793                0,
1794                ts.letter_spacing,
1795                None,
1796            );
1797            let lh = layout.line_h_px;
1798            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1799                let ln = render_text[s..e].to_string();
1800                let draw_y = rect.y + (i as f32) * lh;
1801                if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
1802                    continue;
1803                }
1804                scene.nodes.push(SceneNode::Text {
1805                    rect: repose_core::Rect {
1806                        x: rect.x,
1807                        y: draw_y,
1808                        w: rect.w,
1809                        h: lh,
1810                    },
1811                    text: Arc::<str>::from(ln),
1812                    color: mul_alpha_color(th.on_surface, alpha_accum),
1813                    size: font_val,
1814                    font_family: None,
1815                    text_align: TextAlign::Unspecified,
1816                    font_weight: FontWeight::NORMAL,
1817                    font_style: FontStyle::Normal,
1818                    text_decoration: ts.text_decoration.unwrap_or_default(),
1819                    letter_spacing: 0.0,
1820                    line_height: 0.0,
1821                    extra_style: Default::default(),
1822                    url: None,
1823                    font_variation_settings: None,
1824                });
1825            }
1826        } else {
1827            scene.nodes.push(SceneNode::Text {
1828                rect: repose_core::Rect {
1829                    x: rect.x,
1830                    y: rect.y + text_off_y,
1831                    w: rect.w,
1832                    h: line_h,
1833                },
1834                text: Arc::from(rendered_by_vt(&text_input.value)),
1835                color: mul_alpha_color(th.on_surface, alpha_accum),
1836                size: font_val,
1837                font_family: None,
1838                text_align: TextAlign::Unspecified,
1839                font_weight: FontWeight::NORMAL,
1840                font_style: FontStyle::Normal,
1841                text_decoration: ts.text_decoration.unwrap_or_default(),
1842                letter_spacing: 0.0,
1843                line_height: 0.0,
1844                extra_style: Default::default(),
1845                url: None,
1846                font_variation_settings: None,
1847            });
1848        }
1849    }
1850
1851    // Fire on_text_layout callback with computed layout info
1852    if let Some(ref cb) = text_input.on_text_layout {
1853        let (
1854            line_count,
1855            content_w,
1856            content_h,
1857            first_baseline,
1858            last_baseline,
1859            did_overflow_w,
1860            did_overflow_h,
1861            lines,
1862        ) = if let Some(state_rc) = state {
1863            let st = state_rc.borrow();
1864            let display = if st.text.is_empty() {
1865                text_input.hint.clone()
1866            } else if let Some(ref vt) = text_input.visual_transformation {
1867                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
1868                vt.filter(&annotated).text.text
1869            } else {
1870                st.text.clone()
1871            };
1872            if text_input.multiline {
1873                let l = layout_text_area(
1874                    &display,
1875                    font_val,
1876                    rect.w.max(1.0),
1877                    400,
1878                    0,
1879                    ts.letter_spacing,
1880                    None,
1881                );
1882                let lc = l.ranges.len();
1883                let cw = rect.w.max(0.0);
1884                let ch = (lc as f32 * l.line_h_px).max(0.0);
1885                let line_infos: Vec<_> = l
1886                    .ranges
1887                    .iter()
1888                    .enumerate()
1889                    .map(|(i, &(s, e))| {
1890                        let top = i as f32 * l.line_h_px;
1891                        let bottom = top + l.line_h_px;
1892                        let line_text = &display[s..e];
1893                        let m = measure_text(line_text, font_val, TextMeasureConfig::default());
1894                        let line_w = m.positions.last().copied().unwrap_or(0.0);
1895                        TextLineInfo {
1896                            start: s,
1897                            end: e,
1898                            top,
1899                            baseline: top + l.line_h_px * 0.8,
1900                            bottom,
1901                            left: 0.0,
1902                            right: line_w,
1903                            width: line_w,
1904                        }
1905                    })
1906                    .collect();
1907                let fb = line_infos.first().map(|l| l.baseline).unwrap_or(0.0);
1908                let lb = line_infos.last().map(|l| l.baseline).unwrap_or(0.0);
1909                (lc, cw, ch, fb, lb, cw > rect.w, ch > rect.h, line_infos)
1910            } else {
1911                let m = measure_text(&display, font_val, TextMeasureConfig::default());
1912                let w = m.positions.last().copied().unwrap_or(0.0);
1913                let top = 0.0;
1914                let bottom = line_h.max(font_val);
1915                let baseline = bottom * 0.8;
1916                let line_info = TextLineInfo {
1917                    start: 0,
1918                    end: display.len(),
1919                    top,
1920                    baseline,
1921                    bottom,
1922                    left: 0.0,
1923                    right: w,
1924                    width: w,
1925                };
1926                (
1927                    1,
1928                    w.max(0.0),
1929                    bottom,
1930                    baseline,
1931                    baseline,
1932                    w > rect.w,
1933                    bottom > rect.h,
1934                    vec![line_info],
1935                )
1936            }
1937        } else {
1938            (0, 0.0, 0.0, 0.0, 0.0, false, false, vec![])
1939        };
1940        cb(&repose_core::TextLayoutResult {
1941            line_count,
1942            width_px: content_w,
1943            height_px: content_h,
1944            first_baseline,
1945            last_baseline,
1946            did_overflow_width: did_overflow_w,
1947            did_overflow_height: did_overflow_h,
1948            lines,
1949        });
1950    }
1951
1952    scene.nodes.push(SceneNode::PopClip);
1953}
1954
1955/// Shared view-builder for `BasicTextField`.
1956/// Creates the view with text_input modifier. Painting is handled natively
1957/// by layout.rs when it encounters `modifier.text_input` (Compose-aligned).
1958fn text_field_view(
1959    modifier: Modifier,
1960    hint: String,
1961    value: String,
1962    multiline: bool,
1963    on_change: Option<Rc<dyn Fn(String)>>,
1964    on_submit: Option<Rc<dyn Fn(String)>>,
1965    visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1966    keyboard_type: repose_core::KeyboardType,
1967    capitalization: repose_core::KeyboardCapitalization,
1968    ime_action: repose_core::ImeAction,
1969    enabled: bool,
1970    read_only: bool,
1971    max_lines: Option<usize>,
1972    min_lines: usize,
1973    cursor_color: Option<Color>,
1974    on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
1975    text_style: repose_core::TextStyle,
1976    keyboard_actions: repose_core::KeyboardActions,
1977    interaction_source: Option<repose_core::MutableInteractionSource>,
1978    line_limits: Option<repose_core::TextFieldLineLimits>,
1979    _input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
1980    _output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
1981    _decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
1982    _codepoint_transformation: Option<repose_core::CodepointTransformation>,
1983) -> View {
1984    let modif = modifier.text_input(TextInputConfig {
1985        hint,
1986        multiline,
1987        on_change,
1988        on_submit,
1989        focus_tracker: None,
1990        value,
1991        visual_transformation,
1992        keyboard_type,
1993        capitalization,
1994        ime_action,
1995        enabled,
1996        read_only,
1997        max_lines,
1998        min_lines,
1999        cursor_color,
2000        on_text_layout,
2001        text_style: Some(text_style),
2002        keyboard_actions: Some(keyboard_actions),
2003        interaction_source: interaction_source.as_ref().map(|s| s.source()),
2004        line_limits,
2005    });
2006
2007    View::new(0, ViewKind::Box)
2008        .modifier(modif)
2009        .semantics(Semantics {
2010            role: Role::TextField,
2011            label: None,
2012            focused: false,
2013            enabled,
2014            selectable_group: false,
2015        })
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020    use super::*;
2021
2022    #[test]
2023    fn test_index_for_x_bytes_grapheme() {
2024        let t = "A👍🏽B";
2025        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
2026        let m = measure_text(t, font_px, TextMeasureConfig::default());
2027        for i in 0..m.byte_offsets.len() - 1 {
2028            let b = m.byte_offsets[i];
2029            let _ = &t[..b];
2030        }
2031    }
2032}