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::{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
362pub(crate) fn 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
373pub(crate) fn 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
382/// Find the word boundaries around the given byte index.
383/// Selects alphanumeric+underscore runs; falls back to the grapheme cluster.
384pub(crate) fn word_range(text: &str, byte: usize) -> (usize, usize) {
385    let byte = byte.min(text.len());
386    let is_word = |g: &str| g.chars().all(|c| c.is_alphanumeric() || c == '_');
387
388    let mut start = byte;
389    while start > 0 {
390        let p = prev_grapheme_boundary(text, start);
391        if is_word(&text[p..start]) {
392            start = p;
393        } else {
394            break;
395        }
396    }
397    let mut end = byte;
398    while end < text.len() {
399        let n = next_grapheme_boundary(text, end);
400        if is_word(&text[end..n]) {
401            end = n;
402        } else {
403            break;
404        }
405    }
406    if start == end {
407        let s = if byte == 0 {
408            0
409        } else {
410            prev_grapheme_boundary(text, byte)
411        };
412        let e = next_grapheme_boundary(text, byte);
413        (s, e.max(s))
414    } else {
415        (start, end)
416    }
417}
418
419pub struct TextFieldState {
420    pub text: String,
421    pub selection: Range<usize>,
422    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
423    pub scroll_offset: f32,                // px (x) - current animated display value
424    pub scroll_offset_y: f32,              // px (y) for multiline - current animated display value
425    pub drag_anchor: Option<usize>,        // byte index where drag began
426
427    // Double/triple-tap tracking
428    pub(crate) last_tap_time: Option<Instant>,
429    pub(crate) last_tap_pos: Option<(f32, f32)>,
430    pub(crate) tap_count: u8,
431
432    pub blink_start: Instant,        // caret blink timer
433    pub inner_width: f32,            // px
434    pub inner_height: f32,           // px
435    pub preferred_x_px: Option<f32>, // for Up/Down caret movement in multiline
436    /// When a visual transformation is active, this maps offsets in the
437    /// display text back to offsets in the original text.
438    pub offset_map: Option<Box<dyn OffsetMapping>>,
439    /// The active visual transformation, set during layout.
440    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
441    /// Target horizontal scroll offset (where we're animating toward).
442    pub(crate) scroll_target: f32,
443    /// Target vertical scroll offset.
444    pub(crate) scroll_target_y: f32,
445    /// Spring velocity for horizontal scroll animation.
446    scroll_vel: f32,
447    /// Spring velocity for vertical scroll animation.
448    scroll_vel_y: f32,
449    /// Last time tick_scroll_animation was called (for dt computation).
450    last_scroll_tick: Option<Instant>,
451
452    // Undo/Redo
453    /// Stack of undo operations (most recent at end).
454    undo_stack: Vec<TextUndoOp>,
455    /// Stack of redo operations (most recent at end).
456    redo_stack: Vec<TextUndoOp>,
457    /// Staging area for the latest operation that may still merge.
458    staging_undo: Option<TextUndoOp>,
459}
460
461impl std::fmt::Debug for TextFieldState {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        f.debug_struct("TextFieldState")
464            .field("text", &self.text)
465            .field("selection", &self.selection)
466            .field("composition", &self.composition)
467            .field("scroll_offset", &self.scroll_offset)
468            .field("scroll_offset_y", &self.scroll_offset_y)
469            .field("drag_anchor", &self.drag_anchor)
470            .field("blink_start", &self.blink_start)
471            .field("inner_width", &self.inner_width)
472            .field("inner_height", &self.inner_height)
473            .field("preferred_x_px", &self.preferred_x_px)
474            .field(
475                "offset_map",
476                &self.offset_map.as_ref().map(|_| "<offset_mapping>"),
477            )
478            .field(
479                "visual_transformation",
480                &self.visual_transformation.as_ref().map(|_| "<vt>"),
481            )
482            .field("scroll_target", &self.scroll_target)
483            .field("scroll_target_y", &self.scroll_target_y)
484            .field("can_undo", &self.can_undo())
485            .field("can_redo", &self.can_redo())
486            .field("undo_count", &self.undo_stack.len())
487            .field("redo_count", &self.redo_stack.len())
488            .finish()
489    }
490}
491
492impl Default for TextFieldState {
493    fn default() -> Self {
494        Self::new()
495    }
496}
497
498impl Clone for TextFieldState {
499    fn clone(&self) -> Self {
500        Self {
501            text: self.text.clone(),
502            selection: self.selection.clone(),
503            composition: self.composition.clone(),
504            scroll_offset: self.scroll_offset,
505            scroll_offset_y: self.scroll_offset_y,
506            drag_anchor: self.drag_anchor,
507            last_tap_time: self.last_tap_time,
508            last_tap_pos: self.last_tap_pos,
509            tap_count: self.tap_count,
510            blink_start: self.blink_start,
511            inner_width: self.inner_width,
512            inner_height: self.inner_height,
513            preferred_x_px: self.preferred_x_px,
514            offset_map: self.offset_map.as_ref().map(|m| m.clone_box()),
515            visual_transformation: self.visual_transformation.clone(),
516            scroll_target: self.scroll_target,
517            scroll_target_y: self.scroll_target_y,
518            scroll_vel: self.scroll_vel,
519            scroll_vel_y: self.scroll_vel_y,
520            last_scroll_tick: self.last_scroll_tick,
521            undo_stack: self.undo_stack.clone(),
522            redo_stack: self.redo_stack.clone(),
523            staging_undo: self.staging_undo.clone(),
524        }
525    }
526}
527
528impl TextFieldState {
529    pub fn new() -> Self {
530        Self {
531            text: String::new(),
532            selection: 0..0,
533            composition: None,
534            scroll_offset: 0.0,
535            scroll_offset_y: 0.0,
536            drag_anchor: None,
537            last_tap_time: None,
538            last_tap_pos: None,
539            tap_count: 0,
540            blink_start: Instant::now(),
541            inner_width: 0.0,
542            inner_height: 0.0,
543            preferred_x_px: None,
544            offset_map: None,
545            visual_transformation: None,
546            scroll_target: 0.0,
547            scroll_target_y: 0.0,
548            scroll_vel: 0.0,
549            scroll_vel_y: 0.0,
550            last_scroll_tick: None,
551            undo_stack: Vec::new(),
552            redo_stack: Vec::new(),
553            staging_undo: None,
554        }
555    }
556
557    // Undo/Redo
558
559    /// Whether there is an action to undo.
560    pub fn can_undo(&self) -> bool {
561        !self.undo_stack.is_empty() || self.staging_undo.is_some()
562    }
563
564    /// Whether there is an action to redo.
565    pub fn can_redo(&self) -> bool {
566        !self.redo_stack.is_empty()
567    }
568
569    /// Revert the latest edit. Returns true if an undo was performed.
570    pub fn undo(&mut self) -> bool {
571        self.flush_undo();
572        if let Some(op) = self.undo_stack.pop() {
573            let end = (op.index + op.post_text.len()).min(self.text.len());
574            self.text.replace_range(op.index..end, &op.pre_text);
575            self.selection = op.pre_selection.clone();
576            self.redo_stack.push(op);
577            self.preferred_x_px = None;
578            self.reset_caret_blink();
579            true
580        } else {
581            false
582        }
583    }
584
585    /// Re-apply a previously undone edit. Returns true if a redo was performed.
586    pub fn redo(&mut self) -> bool {
587        if let Some(op) = self.redo_stack.pop() {
588            let end = (op.index + op.pre_text.len()).min(self.text.len());
589            self.text.replace_range(op.index..end, &op.post_text);
590            self.selection = op.post_selection.clone();
591            self.undo_stack.push(op);
592            self.preferred_x_px = None;
593            self.reset_caret_blink();
594            true
595        } else {
596            false
597        }
598    }
599
600    /// Clear all undo/redo history.
601    pub fn clear_undo_history(&mut self) {
602        self.undo_stack.clear();
603        self.redo_stack.clear();
604        self.staging_undo = None;
605    }
606
607    /// Push a [TextUndoOp] to the staging area, possibly merging with the
608    /// previous staging operation. Flushes staging to the undo stack when
609    /// merge is not possible.
610    fn record_edit(&mut self, op: TextUndoOp) {
611        if let Some(staging) = self.staging_undo.take() {
612            if let Some(merged) = staging.try_merge(&op) {
613                self.staging_undo = Some(merged);
614                return;
615            }
616            // Can't merge: flush staging to undo stack
617            self.undo_stack.push(staging);
618            self.redo_stack.clear();
619            // Enforce capacity: drop oldest entries
620            while self.undo_stack.len() + 1 > TEXT_UNDO_CAPACITY {
621                self.undo_stack.remove(0);
622            }
623        }
624        self.staging_undo = Some(op);
625    }
626
627    /// Flush the staging operation into the undo stack.
628    fn flush_undo(&mut self) {
629        if let Some(op) = self.staging_undo.take() {
630            self.undo_stack.push(op);
631            self.redo_stack.clear();
632            while self.undo_stack.len() > TEXT_UNDO_CAPACITY {
633                self.undo_stack.remove(0);
634            }
635        }
636    }
637
638    fn insert_text_impl(&mut self, text: &str, can_merge: bool) {
639        let start = self.selection.start.min(self.text.len());
640        let end = self.selection.end.min(self.text.len());
641        let pre_text = self.text[start..end].to_string();
642        let pre_selection = self.selection.clone();
643
644        self.text.replace_range(start..end, text);
645        let new_pos = start + text.len();
646        self.selection = new_pos..new_pos;
647        self.preferred_x_px = None;
648        self.reset_caret_blink();
649
650        if !pre_text.is_empty() || !text.is_empty() {
651            self.record_edit(TextUndoOp {
652                index: start,
653                pre_text,
654                post_text: text.to_string(),
655                pre_selection,
656                post_selection: self.selection.clone(),
657                time: Instant::now(),
658                can_merge,
659            });
660        }
661    }
662
663    pub fn insert_text(&mut self, text: &str) {
664        self.insert_text_impl(text, true);
665    }
666
667    /// Like `insert_text` but marks the operation as unmergeable (for cut/paste).
668    pub fn insert_text_atomic(&mut self, text: &str) {
669        self.insert_text_impl(text, false);
670    }
671
672    pub fn delete_backward(&mut self) {
673        if self.selection.start == self.selection.end {
674            let pos = self.selection.start.min(self.text.len());
675            if pos > 0 {
676                let prev = prev_grapheme_boundary(&self.text, pos);
677                let pre_text = self.text[prev..pos].to_string();
678                let pre_selection = self.selection.clone();
679                self.text.replace_range(prev..pos, "");
680                self.selection = prev..prev;
681                self.preferred_x_px = None;
682                self.reset_caret_blink();
683                self.record_edit(TextUndoOp {
684                    index: prev,
685                    pre_text,
686                    post_text: String::new(),
687                    pre_selection,
688                    post_selection: self.selection.clone(),
689                    time: Instant::now(),
690                    can_merge: true,
691                });
692            }
693        } else {
694            self.insert_text_impl("", true);
695        }
696        self.preferred_x_px = None;
697        self.reset_caret_blink();
698    }
699
700    pub fn delete_forward(&mut self) {
701        if self.selection.start == self.selection.end {
702            let pos = self.selection.start.min(self.text.len());
703            if pos < self.text.len() {
704                let next = next_grapheme_boundary(&self.text, pos);
705                let pre_text = self.text[pos..next].to_string();
706                let pre_selection = self.selection.clone();
707                self.text.replace_range(pos..next, "");
708                self.preferred_x_px = None;
709                self.reset_caret_blink();
710                self.record_edit(TextUndoOp {
711                    index: pos,
712                    pre_text,
713                    post_text: String::new(),
714                    pre_selection,
715                    post_selection: self.selection.clone(),
716                    time: Instant::now(),
717                    can_merge: true,
718                });
719            }
720        } else {
721            self.insert_text_impl("", true);
722        }
723        self.preferred_x_px = None;
724        self.reset_caret_blink();
725    }
726
727    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool) {
728        let mut pos = self.selection.end.min(self.text.len());
729        if delta < 0 {
730            for _ in 0..delta.unsigned_abs() {
731                pos = prev_grapheme_boundary(&self.text, pos);
732            }
733        } else if delta > 0 {
734            for _ in 0..(delta as usize) {
735                pos = next_grapheme_boundary(&self.text, pos);
736            }
737        }
738        if extend_selection {
739            self.selection.end = pos;
740        } else {
741            self.selection = pos..pos;
742        }
743        self.preferred_x_px = None;
744        self.reset_caret_blink();
745    }
746
747    pub fn selected_text(&self) -> String {
748        if self.selection.start == self.selection.end {
749            String::new()
750        } else {
751            self.text[self.selection.clone()].to_string()
752        }
753    }
754
755    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
756        if text.is_empty() {
757            if let Some(range) = self.composition.take() {
758                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
759                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
760                if s <= e {
761                    self.text.replace_range(s..e, "");
762                    self.selection = s..s;
763                }
764            }
765            self.preferred_x_px = None;
766            self.reset_caret_blink();
767            return;
768        }
769
770        let anchor_start;
771        if let Some(r) = self.composition.take() {
772            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
773            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
774            if e < s {
775                std::mem::swap(&mut s, &mut e);
776            }
777            self.text.replace_range(s..e, &text);
778            anchor_start = s;
779        } else {
780            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
781            self.text.insert_str(pos, &text);
782            anchor_start = pos;
783        }
784
785        self.composition = Some(anchor_start..(anchor_start + text.len()));
786
787        if let Some((c0, c1)) = cursor {
788            let b0 = char_to_byte(&text, c0);
789            let b1 = char_to_byte(&text, c1);
790            self.selection = (anchor_start + b0)..(anchor_start + b1);
791        } else {
792            let end = anchor_start + text.len();
793            self.selection = end..end;
794        }
795
796        self.preferred_x_px = None;
797        self.reset_caret_blink();
798    }
799
800    pub fn commit_composition(&mut self, text: String) {
801        let pre_selection = self.selection.clone();
802        if let Some(r) = self.composition.take() {
803            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
804            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
805            let pre_text = self.text[s..e].to_string();
806            self.text.replace_range(s..e, &text);
807            let new_pos = s + text.len();
808            self.selection = new_pos..new_pos;
809            self.preferred_x_px = None;
810            self.reset_caret_blink();
811            if !pre_text.is_empty() || !text.is_empty() {
812                self.record_edit(TextUndoOp {
813                    index: s,
814                    pre_text,
815                    post_text: text,
816                    pre_selection,
817                    post_selection: self.selection.clone(),
818                    time: Instant::now(),
819                    can_merge: true,
820                });
821            }
822        } else {
823            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
824            self.text.insert_str(pos, &text);
825            let new_pos = pos + text.len();
826            self.selection = new_pos..new_pos;
827            self.preferred_x_px = None;
828            self.reset_caret_blink();
829            if !text.is_empty() {
830                self.record_edit(TextUndoOp {
831                    index: pos,
832                    pre_text: String::new(),
833                    post_text: text,
834                    pre_selection,
835                    post_selection: self.selection.clone(),
836                    time: Instant::now(),
837                    can_merge: true,
838                });
839            }
840        }
841    }
842
843    pub fn cancel_composition(&mut self) {
844        if let Some(r) = self.composition.take() {
845            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
846            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
847            if s <= e {
848                self.text.replace_range(s..e, "");
849                self.selection = s..s;
850            }
851        }
852        self.preferred_x_px = None;
853        self.reset_caret_blink();
854    }
855
856    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
857        if self.selection.start != self.selection.end {
858            let start = self.selection.start.min(self.text.len());
859            let end = self.selection.end.min(self.text.len());
860            self.text.replace_range(start..end, "");
861            self.selection = start..start;
862            self.preferred_x_px = None;
863            self.reset_caret_blink();
864            return;
865        }
866
867        let caret = self.selection.end.min(self.text.len());
868        let start_raw = caret.saturating_sub(before_bytes);
869        let end_raw = (caret + after_bytes).min(self.text.len());
870
871        let start = prev_grapheme_boundary(&self.text, start_raw);
872        let end = next_grapheme_boundary(&self.text, end_raw);
873        if start < end {
874            self.text.replace_range(start..end, "");
875            self.selection = start..start;
876        }
877        self.preferred_x_px = None;
878        self.reset_caret_blink();
879    }
880
881    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
882        let idx = idx_byte.min(self.text.len());
883        if extend {
884            let anchor = self.selection.start;
885            self.selection = anchor.min(idx)..anchor.max(idx);
886            self.drag_anchor = Some(anchor);
887        } else {
888            self.selection = idx..idx;
889            self.drag_anchor = Some(idx);
890        }
891        self.preferred_x_px = None;
892        self.reset_caret_blink();
893    }
894
895    pub fn drag_to(&mut self, idx_byte: usize) {
896        if let Some(anchor) = self.drag_anchor {
897            let i = idx_byte.min(self.text.len());
898            self.selection = anchor.min(i)..anchor.max(i);
899        }
900        self.preferred_x_px = None;
901        self.reset_caret_blink();
902        if self.selection.start < self.selection.end {
903            repose_core::clipboard::set_primary_selection(
904                &self.text[self.selection.start..self.selection.end],
905            );
906        }
907    }
908    pub fn end_drag(&mut self) {
909        self.drag_anchor = None;
910        if self.selection.start < self.selection.end {
911            repose_core::clipboard::set_primary_selection(
912                &self.text[self.selection.start..self.selection.end],
913            );
914        }
915    }
916
917    pub fn handle_pointer_down(&mut self, idx_byte: usize, pos_px: (f32, f32), shift: bool) {
918        const DOUBLE_TAP_MS: u64 = 300;
919        const TAP_SLOP_PX: f32 = 12.0;
920
921        let now = Instant::now();
922        let mut count = self.tap_count;
923        if let (Some(t), Some(p)) = (self.last_tap_time, self.last_tap_pos) {
924            let dt = now.saturating_duration_since(t);
925            let dist = ((pos_px.0 - p.0).powi(2) + (pos_px.1 - p.1).powi(2)).sqrt();
926            if dt < Duration::from_millis(DOUBLE_TAP_MS) && dist < TAP_SLOP_PX {
927                count = count.saturating_add(1);
928            } else {
929                count = 1;
930            }
931        } else {
932            count = 1;
933        }
934        self.tap_count = count;
935        self.last_tap_time = Some(now);
936        self.last_tap_pos = Some(pos_px);
937
938        let idx = idx_byte.min(self.text.len());
939
940        if count >= 3 {
941            // Triple-tap: select all
942            self.selection = 0..self.text.len();
943            self.drag_anchor = None;
944            self.preferred_x_px = None;
945            self.reset_caret_blink();
946            if self.selection.end > 0 {
947                repose_core::clipboard::set_primary_selection(&self.text);
948            }
949            return;
950        }
951
952        if count == 2 {
953            // Double-tap: select word
954            let (s, e) = word_range(&self.text, idx);
955            self.selection = s..e;
956            self.drag_anchor = Some(s);
957            self.preferred_x_px = None;
958            self.reset_caret_blink();
959            if e > s {
960                repose_core::clipboard::set_primary_selection(&self.text[s..e]);
961            }
962            return;
963        }
964
965        // Single tap
966        self.begin_drag(idx, shift);
967    }
968
969    /// Select the word at the given byte index.
970    pub fn select_word_at(&mut self, byte: usize) {
971        let (s, e) = word_range(&self.text, byte.min(self.text.len()));
972        self.selection = s..e;
973        self.drag_anchor = Some(s);
974        self.preferred_x_px = None;
975        self.reset_caret_blink();
976    }
977
978    /// Select all text.
979    pub fn select_all(&mut self) {
980        self.selection = 0..self.text.len();
981        self.drag_anchor = None;
982        self.preferred_x_px = None;
983        self.reset_caret_blink();
984    }
985
986    pub fn caret_index(&self) -> usize {
987        self.selection.end
988    }
989
990    /// Keep caret visible inside inner content width (px).
991    /// `inset_px` is a small padding (px) to avoid hugging edges.
992    /// Sets the scroll target for smooth animated scrolling.
993    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
994        self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
995    }
996
997    /// Keep caret visible inside an inner rect (for multiline).
998    /// Sets the scroll target for smooth animated scrolling.
999    pub fn ensure_caret_visible_xy(
1000        &mut self,
1001        caret_x_px: f32,
1002        caret_y_px: f32,
1003        inner_w_px: f32,
1004        inner_h_px: f32,
1005        inset_px: f32,
1006    ) {
1007        let inset_px = inset_px.max(0.0);
1008
1009        // Compute target X scroll based on current display offset
1010        let left_px = self.scroll_offset + inset_px;
1011        let right_px = self.scroll_offset + inner_w_px - inset_px;
1012        if caret_x_px < left_px {
1013            self.scroll_target = (caret_x_px - inset_px).max(0.0);
1014        } else if caret_x_px > right_px {
1015            self.scroll_target = (caret_x_px - inner_w_px + inset_px).max(0.0);
1016        }
1017
1018        // Compute target Y scroll based on current display offset
1019        let top_px = self.scroll_offset_y + inset_px;
1020        let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
1021        if caret_y_px < top_px {
1022            self.scroll_target_y = (caret_y_px - inset_px).max(0.0);
1023        } else if caret_y_px > bot_px {
1024            self.scroll_target_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
1025        }
1026    }
1027
1028    pub fn clamp_scroll(&mut self, content_h_px: f32) {
1029        let max_y = (content_h_px - self.inner_height).max(0.0);
1030        self.scroll_target_y = self.scroll_target_y.clamp(0.0, max_y);
1031        if self.scroll_target_y.is_nan() {
1032            self.scroll_target_y = 0.0;
1033        }
1034    }
1035
1036    pub fn reset_caret_blink(&mut self) {
1037        self.blink_start = Instant::now();
1038    }
1039    pub fn caret_visible(&self) -> bool {
1040        const PERIOD: Duration = Duration::from_millis(500);
1041        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
1042    }
1043
1044    /// If the selection is collapsed (caret is visible), return the [`Instant`]
1045    /// of the next 500 ms blink boundary.
1046    pub fn next_blink_deadline(&self) -> Option<Instant> {
1047        if self.selection.start != self.selection.end {
1048            return None;
1049        }
1050        const PERIOD_MS: u128 = 500;
1051        let now = Instant::now();
1052        let elapsed = now.saturating_duration_since(self.blink_start).as_millis();
1053        let next_tick = (elapsed / PERIOD_MS) + 1;
1054        Some(self.blink_start + Duration::from_millis((next_tick * PERIOD_MS) as u64))
1055    }
1056
1057    /// Seed state from a controlled value (first focus / first click).
1058    pub fn with_text(text: impl Into<String>) -> Self {
1059        let mut s = Self::new();
1060        s.text = text.into();
1061        let len = s.text.len();
1062        s.selection = len..len;
1063        s
1064    }
1065
1066    /// Apply host-controlled value without clobbering IME composition.
1067    /// Call this *before* paint and *before* pointer→index mapping.
1068    pub fn apply_controlled_value(&mut self, value: &str) {
1069        if self.text.as_str() == value {
1070            return;
1071        }
1072        if self.composition.is_some() {
1073            return;
1074        }
1075        self.text = value.to_string();
1076        let len = self.text.len();
1077        let ns = clamp_to_char_boundary(&self.text, self.selection.start.min(len));
1078        let ne = clamp_to_char_boundary(&self.text, self.selection.end.min(len));
1079        self.selection = ns..ne;
1080        self.drag_anchor = None;
1081    }
1082
1083    pub fn set_inner_width(&mut self, w_px: f32) {
1084        self.inner_width = w_px.max(0.0);
1085        if self.scroll_offset.is_nan() {
1086            self.scroll_offset = 0.0;
1087        }
1088        if self.scroll_target.is_nan() {
1089            self.scroll_target = 0.0;
1090        }
1091    }
1092    pub fn set_inner_height(&mut self, h_px: f32) {
1093        self.inner_height = h_px.max(0.0);
1094        if self.scroll_offset_y.is_nan() {
1095            self.scroll_offset_y = 0.0;
1096        }
1097        if self.scroll_target_y.is_nan() {
1098            self.scroll_target_y = 0.0;
1099        }
1100    }
1101
1102    /// Advance scroll animation by actual wall-clock dt using spring physics.
1103    /// Call this once per frame before reading [scroll_offset] / [scroll_offset_y].
1104    /// On the first call after a target change, snaps immediately to avoid 1-frame delay.
1105    pub fn tick_scroll_animation(&mut self) {
1106        let now = Instant::now();
1107        let dt = match self.last_scroll_tick {
1108            Some(prev) => {
1109                let d = now.saturating_duration_since(prev).as_secs_f32();
1110                d.min(0.05) // cap to 50ms to avoid jumps after pause
1111            }
1112            None => {
1113                // First tick: snap to target immediately, but record the time
1114                // so subsequent ticks produce a smooth spring.
1115                self.last_scroll_tick = Some(now);
1116                self.scroll_offset = self.scroll_target;
1117                self.scroll_vel = 0.0;
1118                self.scroll_offset_y = self.scroll_target_y;
1119                self.scroll_vel_y = 0.0;
1120                return;
1121            }
1122        };
1123        self.last_scroll_tick = Some(now);
1124
1125        // X axis
1126        if dt > 0.0 {
1127            let dx = self.scroll_target - self.scroll_offset;
1128            let near_x = dx.abs() < 0.5 && self.scroll_vel.abs() < 0.5;
1129            if near_x {
1130                self.scroll_offset = self.scroll_target;
1131                self.scroll_vel = 0.0;
1132            } else {
1133                let force_x = SCROLL_STIFFNESS * dx - SCROLL_DAMPING * self.scroll_vel;
1134                self.scroll_vel += force_x * dt;
1135                self.scroll_offset += self.scroll_vel * dt;
1136                // Overshoot protection: clamp to target if we'd pass it this frame
1137                if (self.scroll_target - self.scroll_offset).signum() != dx.signum() && dx != 0.0 {
1138                    self.scroll_offset = self.scroll_target;
1139                    self.scroll_vel = 0.0;
1140                }
1141            }
1142        }
1143
1144        // Y axis
1145        if dt > 0.0 {
1146            let dy = self.scroll_target_y - self.scroll_offset_y;
1147            let near_y = dy.abs() < 0.5 && self.scroll_vel_y.abs() < 0.5;
1148            if near_y {
1149                self.scroll_offset_y = self.scroll_target_y;
1150                self.scroll_vel_y = 0.0;
1151            } else {
1152                let force_y = SCROLL_STIFFNESS * dy - SCROLL_DAMPING * self.scroll_vel_y;
1153                self.scroll_vel_y += force_y * dt;
1154                self.scroll_offset_y += self.scroll_vel_y * dt;
1155                if (self.scroll_target_y - self.scroll_offset_y).signum() != dy.signum()
1156                    && dy != 0.0
1157                {
1158                    self.scroll_offset_y = self.scroll_target_y;
1159                    self.scroll_vel_y = 0.0;
1160                }
1161            }
1162        }
1163    }
1164}
1165
1166/// Configuration for `BasicTextField` / `BasicSecureTextField`.
1167///
1168/// Use `..Default::default()` for unset fields:
1169/// ```ignore
1170/// BasicTextField(state, modifier, "Hint", TextFieldConfig {
1171///     enabled: false,
1172///     ..Default::default()
1173/// })
1174/// ```
1175#[derive(Clone)]
1176pub struct TextFieldConfig {
1177    /// When false, the text field is not editable, not focusable, and input is not selectable (-> `enabled`).
1178    pub enabled: bool,
1179    /// When true, the text field can be focused and text can be selected/copied, but not modified (-> `readOnly`).
1180    pub read_only: bool,
1181    /// Input transformation (-> `inputTransformation`). Transforms text before it is applied.
1182    pub input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
1183    /// Style for the text content (-> `textStyle`).
1184    pub text_style: repose_core::TextStyle,
1185    /// Platform keyboard configuration hints (-> `keyboardOptions`).
1186    pub keyboard_options: repose_core::KeyboardOptions,
1187    /// Per-action IME callback (-> `onKeyboardAction`).
1188    pub on_keyboard_action: Option<Rc<dyn repose_core::KeyboardActionHandler>>,
1189    /// Line limits (-> `TextFieldLineLimits`).
1190    pub line_limits: repose_core::TextFieldLineLimits,
1191    /// Callback invoked after each text layout computation (-> `onTextLayout`).
1192    pub on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
1193    /// Interaction source for tracking focus/press/hover state.
1194    pub interaction_source: Option<repose_core::MutableInteractionSource>,
1195    /// Tracks focus state during layout. The cell is set to `true` while this
1196    /// field is the focused text input, `false` otherwise.
1197    pub focus_tracker: Option<Rc<Cell<bool>>>,
1198    /// Cursor brush (-> `cursorBrush`). `None` -> theme default (`on_surface`).
1199    pub cursor_brush: Option<repose_core::Brush>,
1200    /// Output transformation (-> `outputTransformation`). Transforms text for display only.
1201    pub output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
1202    /// Decorator (-> `decorator`). Wraps the inner text field with custom decorations.
1203    pub decorator: Option<Rc<dyn repose_core::TextFieldDecorator>>,
1204    /// Internal codepoint transformation for password obfuscation (-> `codepointTransformation`).
1205    pub codepoint_transformation: Option<repose_core::CodepointTransformation>,
1206    /// Text obfuscation mode (-> `textObfuscationMode`). Used by `BasicSecureTextField`.
1207    pub text_obfuscation_mode: repose_core::TextObfuscationMode,
1208    /// Character used for text obfuscation (-> `textObfuscationCharacter`). Used by `BasicSecureTextField`.
1209    pub text_obfuscation_character: char,
1210
1211    // Legacy / reposé-specific (for migration convenience, kept in config)
1212    pub on_change: Option<Rc<dyn Fn(String)>>,
1213    pub on_submit: Option<Rc<dyn Fn(String)>>,
1214    pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1215    pub decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
1216}
1217
1218impl Default for TextFieldConfig {
1219    fn default() -> Self {
1220        Self {
1221            enabled: true,
1222            read_only: false,
1223            input_transformation: None,
1224            text_style: Default::default(),
1225            keyboard_options: repose_core::KeyboardOptions::DEFAULT,
1226            on_keyboard_action: None,
1227            line_limits: repose_core::TextFieldLineLimits::MultiLine {
1228                min_height_in_lines: 1,
1229                max_height_in_lines: usize::MAX,
1230            },
1231            on_text_layout: None,
1232            interaction_source: None,
1233            focus_tracker: None,
1234            cursor_brush: None,
1235            output_transformation: None,
1236            decorator: None,
1237            codepoint_transformation: None,
1238            text_obfuscation_mode: repose_core::TextObfuscationMode::System,
1239            text_obfuscation_character: '\u{2022}',
1240            on_change: None,
1241            on_submit: None,
1242            visual_transformation: None,
1243            decoration_box: None,
1244        }
1245    }
1246}
1247
1248/// State-based text field. Corresponds to Compose's `BasicTextField(state: TextFieldState, ...)`.
1249///
1250/// The state is managed externally and all editing is reflected in the `TextFieldState`
1251/// object passed to the platform runner via `set_textfield_state`.
1252///
1253/// # Example
1254/// ```ignore
1255/// let state = Rc::new(RefCell::new(TextFieldState::new("")));
1256/// BasicTextField(state.clone(), Modifier::new(), "Hint", TextFieldConfig {
1257///     enabled: false,
1258///     ..Default::default()
1259/// })
1260/// ```
1261pub fn BasicTextField(
1262    state: Rc<RefCell<TextFieldState>>,
1263    modifier: repose_core::Modifier,
1264    hint: impl Into<String>,
1265    config: TextFieldConfig,
1266) -> repose_core::View {
1267    let (single_line, max_lines, min_lines) = match config.line_limits {
1268        repose_core::TextFieldLineLimits::SingleLine => (true, 1, 1),
1269        repose_core::TextFieldLineLimits::MultiLine {
1270            min_height_in_lines,
1271            max_height_in_lines,
1272        } => (false, max_height_in_lines, min_height_in_lines),
1273    };
1274
1275    let ka = if let Some(ref handler) = config.on_keyboard_action {
1276        let handler = handler.clone();
1277        repose_core::KeyboardActions {
1278            on_done: Some({
1279                let h = handler.clone();
1280                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1281                    h.on_keyboard_action(&|| {})
1282                })
1283            }),
1284            on_go: Some({
1285                let h = handler.clone();
1286                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1287                    h.on_keyboard_action(&|| {})
1288                })
1289            }),
1290            on_next: Some({
1291                let h = handler.clone();
1292                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1293                    h.on_keyboard_action(&|| {})
1294                })
1295            }),
1296            on_previous: Some({
1297                let h = handler.clone();
1298                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1299                    h.on_keyboard_action(&|| {})
1300                })
1301            }),
1302            on_search: Some({
1303                let h = handler.clone();
1304                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1305                    h.on_keyboard_action(&|| {})
1306                })
1307            }),
1308            on_send: Some({
1309                Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
1310                    handler.on_keyboard_action(&|| {})
1311                })
1312            }),
1313        }
1314    } else {
1315        repose_core::KeyboardActions::default()
1316    };
1317
1318    let decoration_box = config
1319        .decorator
1320        .map(|d| Rc::new(move |inner: repose_core::View| d.decorate(inner)) as Rc<dyn Fn(_) -> _>);
1321
1322    let cursor_color = config.cursor_brush.and_then(|b| match b {
1323        repose_core::Brush::Solid(c) => Some(c),
1324        _ => None,
1325    });
1326
1327    let value = state.borrow().text.clone();
1328    let key = state.as_ptr() as u64;
1329    set_textfield_state(key, state.clone());
1330
1331    let state_on_change = {
1332        let s = state.clone();
1333        move |new_value: String| {
1334            s.borrow_mut().text = new_value;
1335        }
1336    };
1337
1338    let merged_on_change: Option<Rc<dyn Fn(String)>> =
1339        if let Some(ref cfg_on_change) = config.on_change {
1340            let a = Rc::new(state_on_change) as Rc<dyn Fn(String)>;
1341            let b = cfg_on_change.clone();
1342            Some(Rc::new(move |v: String| {
1343                a(v.clone());
1344                b(v);
1345            }) as Rc<dyn Fn(String)>)
1346        } else {
1347            Some(Rc::new(state_on_change) as Rc<dyn Fn(String)>)
1348        };
1349
1350    text_field_view(
1351        modifier,
1352        hint.into(),
1353        value,
1354        !single_line,
1355        merged_on_change,
1356        config.on_submit,
1357        config.visual_transformation,
1358        config.keyboard_options.keyboard_type,
1359        config.keyboard_options.capitalization,
1360        config.keyboard_options.ime_action,
1361        config.keyboard_options.auto_correct_enabled,
1362        config.enabled,
1363        config.read_only,
1364        Some(max_lines),
1365        min_lines,
1366        cursor_color,
1367        config.on_text_layout,
1368        config.text_style,
1369        ka,
1370        config.interaction_source,
1371        config.focus_tracker,
1372        Some(config.line_limits),
1373        config.input_transformation,
1374        config.output_transformation,
1375        decoration_box,
1376        config.codepoint_transformation,
1377    )
1378}
1379
1380/// Secure text field for password entry. Corresponds to Compose's `BasicSecureTextField`.
1381///
1382/// Wraps `BasicTextField` with secure defaults: single-line, password keyboard,
1383/// text obfuscation, and disabled cut/copy.
1384pub fn BasicSecureTextField(
1385    state: Rc<RefCell<TextFieldState>>,
1386    modifier: repose_core::Modifier,
1387    config: TextFieldConfig,
1388) -> repose_core::View {
1389    let mask = config.text_obfuscation_character;
1390    let secure_config = TextFieldConfig {
1391        line_limits: repose_core::TextFieldLineLimits::SingleLine,
1392        keyboard_options: repose_core::KeyboardOptions::SECURE_TEXT_FIELD,
1393        visual_transformation: match config.text_obfuscation_mode {
1394            repose_core::TextObfuscationMode::Visible => None,
1395            _ => Some(Rc::new(repose_core::PasswordVisualTransformation { mask })
1396                as Rc<dyn repose_core::VisualTransformation>),
1397        },
1398        ..config
1399    };
1400    BasicTextField(state, modifier, "", secure_config)
1401}
1402
1403#[derive(Clone, Debug)]
1404pub struct TextAreaLayout {
1405    pub ranges: Vec<(usize, usize)>,
1406    pub line_h_px: f32,
1407}
1408
1409pub fn layout_text_area(
1410    text: &str,
1411    font_px: f32,
1412    wrap_w_px: f32,
1413    font_weight: u16,
1414    font_style: u8,
1415    letter_spacing: f32,
1416    font_variation_settings: Option<&str>,
1417) -> TextAreaLayout {
1418    let line_h = font_px;
1419    let (ranges, _) = repose_text::wrap_line_ranges(
1420        text,
1421        font_px,
1422        wrap_w_px.max(1.0),
1423        None,
1424        true,
1425        font_weight,
1426        font_style,
1427        letter_spacing,
1428        font_variation_settings,
1429    );
1430    TextAreaLayout {
1431        ranges,
1432        line_h_px: line_h,
1433    }
1434}
1435
1436/// Return (line_index, local_byte, global_byte) for a global byte index.
1437fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
1438    if ranges.is_empty() {
1439        return (0, 0, b);
1440    }
1441    for (i, (s, e)) in ranges.iter().enumerate() {
1442        if b < *s {
1443            if i == 0 {
1444                return (0, 0, b);
1445            }
1446            let (ps, pe) = ranges[i - 1];
1447            let local = pe.saturating_sub(ps);
1448            return (i - 1, local, ps + local);
1449        }
1450        if b < *e {
1451            let local = b.saturating_sub(*s).min(e.saturating_sub(*s));
1452            return (i, local, *s + local);
1453        }
1454        if b == *e {
1455            if let Some((ns, _ne)) = ranges.get(i + 1)
1456                && *ns == b
1457            {
1458                return (i + 1, 0, b);
1459            }
1460            let local = e.saturating_sub(*s);
1461            return (i, local, *s + local);
1462        }
1463    }
1464    let (ls, le) = ranges[ranges.len() - 1];
1465    let local = le.saturating_sub(ls);
1466    (ranges.len() - 1, local, ls + local)
1467}
1468
1469/// Compute caret (x, y) in px relative to the top-left of the inner content (not scrolled).
1470pub fn caret_xy_for_byte(
1471    text: &str,
1472    font_px: f32,
1473    wrap_w_px: f32,
1474    byte: usize,
1475) -> (f32, f32, usize) {
1476    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1477    let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
1478    let (li, local, _) = locate_byte_in_ranges(ranges, byte);
1479    let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
1480    let line = &text[s..e];
1481    let m = measure_text(line, font_px, TextMeasureConfig::default());
1482    let ci = byte_to_char_index(&m, local);
1483    let x = m.positions.get(ci).copied().unwrap_or(0.0);
1484    let y = (li as f32) * line_h;
1485    (x, y, li)
1486}
1487
1488/// Given x/y (px) relative to inner content (not scrolled), return nearest grapheme boundary byte index.
1489pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
1490    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1491    let li = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
1492    let li = li.min(layout.ranges.len().saturating_sub(1));
1493    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1494    let line = &text[s..e];
1495    let local = index_for_x_bytes(line, font_px, x_px.max(0.0), 400, 0);
1496    (s + local).min(text.len())
1497}
1498
1499/// Move caret up/down in wrapped multiline text, keeping a preferred x column.
1500pub fn move_caret_vertical(
1501    text: &str,
1502    font_px: f32,
1503    wrap_w_px: f32,
1504    cur_byte: usize,
1505    dir: i32, // -1 up, +1 down
1506    preferred_x: Option<f32>,
1507) -> (usize, f32) {
1508    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1509    if layout.ranges.is_empty() {
1510        return (cur_byte, preferred_x.unwrap_or(0.0));
1511    }
1512    let (x, _y, li) = caret_xy_for_byte(text, font_px, wrap_w_px, cur_byte);
1513    let px = preferred_x.unwrap_or(x);
1514    let mut nli = li as i32 + dir;
1515    nli = nli.clamp(0, (layout.ranges.len().saturating_sub(1)) as i32);
1516    let nli = nli as usize;
1517    let (s, e) = layout.ranges[nli];
1518    let line = &text[s..e];
1519    let local = index_for_x_bytes(line, font_px, px.max(0.0), 400, 0);
1520    ((s + local).min(text.len()), px)
1521}
1522
1523/// Move to start/end of current visual line.
1524pub fn line_home_end(
1525    text: &str,
1526    font_px: f32,
1527    wrap_w_px: f32,
1528    cur_byte: usize,
1529    to_end: bool,
1530) -> usize {
1531    let layout = layout_text_area(text, font_px, wrap_w_px, 400, 0, 0.0, None);
1532    let (li, _local, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
1533    let (s, e) = layout.ranges.get(li).copied().unwrap_or((0, 0));
1534    if to_end { e } else { s }
1535}
1536
1537fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
1538    if i >= s.len() {
1539        return s.len();
1540    }
1541    if s.is_char_boundary(i) {
1542        return i;
1543    }
1544    let mut j = i;
1545    while j > 0 && !s.is_char_boundary(j) {
1546        j -= 1;
1547    }
1548    j
1549}
1550
1551fn char_to_byte(s: &str, ci: usize) -> usize {
1552    if ci == 0 {
1553        0
1554    } else {
1555        s.char_indices().nth(ci).map(|(i, _)| i).unwrap_or(s.len())
1556    }
1557}
1558
1559/// Paint a text field into the scene. Called by layout.rs when
1560/// `modifier.text_input.is_some()`. This is the Compose-equivalent of
1561/// `TextFieldCoreModifierNode.draw()` - the engine handles painting natively
1562/// when the text_input modifier is present (no caller-side painter needed).
1563///
1564/// Behavior per Compose BasicTextField:
1565/// - `text_input.enabled=false`: no cursor, no selection highlight, text rendered normally
1566/// - `text_input.read_only=true`: no cursor, selection highlight rendered
1567/// - `cursor_color`: overrides cursor brush
1568/// - `max_lines`: caps rendered lines (clip applied by container)
1569/// - `on_text_layout`: called after layout computation
1570pub(crate) fn paint_text_field(
1571    scene: &mut Scene,
1572    rect: repose_core::Rect,
1573    text_input: &TextInputConfig,
1574    state: Option<&Rc<RefCell<TextFieldState>>>,
1575    is_focused: bool,
1576    clip_rounded: Option<[f32; 4]>,
1577    alpha_accum: f32,
1578) {
1579    let ts = text_input.text_style.clone().unwrap_or_default();
1580    let font_size_dp = if ts.font_size != 0.0 {
1581        ts.font_size
1582    } else {
1583        TF_FONT_DP
1584    };
1585    let font_val = dp_to_px(font_size_dp) * locals::text_scale().0;
1586    let line_h = if ts.line_height != 0.0 {
1587        dp_to_px(ts.line_height) * locals::text_scale().0
1588    } else if text_input.multiline {
1589        0.0 // sentinel -> renderer uses Normal line height (font-metric-based)
1590    } else {
1591        font_val // single-line needs tp use font em-size for correct cursor–text alignment
1592    };
1593    let text_off_y = (rect.h - line_h.max(font_val)) / 2.0;
1594
1595    let clip_radius = clip_rounded.unwrap_or([0.0; 4]).map(dp_to_px);
1596    scene.nodes.push(SceneNode::PushClip {
1597        rect,
1598        radius: clip_radius,
1599        op: repose_core::ClipOp::Intersect,
1600    });
1601
1602    let th = locals::theme();
1603    let show_selection = text_input.enabled;
1604    let show_cursor = text_input.enabled && !text_input.read_only;
1605    let cursor_color = text_input.cursor_color.unwrap_or(th.on_surface);
1606    let rendered_by_vt = |original: &str| -> String {
1607        if let Some(ref vt) = text_input.visual_transformation {
1608            let annotated = repose_core::AnnotatedString::new(original.to_string(), vec![]);
1609            vt.filter(&annotated).text.text
1610        } else {
1611            original.to_string()
1612        }
1613    };
1614
1615    if let Some(state_rc) = state {
1616        let st = state_rc.borrow();
1617
1618        if !text_input.multiline {
1619            // Single-line
1620            let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
1621                rendered_by_vt(&st.text)
1622            } else {
1623                st.text.clone()
1624            };
1625            let has_vt = text_input.visual_transformation.is_some();
1626            let m = measure_text(
1627                &measure_for,
1628                font_val,
1629                TextMeasureConfig {
1630                    font_family: ts.font_family,
1631                    font_weight: ts.font_weight.unwrap_or(400),
1632                    font_style: ts.font_style.unwrap_or(0),
1633                    letter_spacing: ts.letter_spacing,
1634                    font_variation_settings: None,
1635                },
1636            );
1637
1638            // Selection highlight
1639            if show_selection && st.selection.start != st.selection.end {
1640                let start_off = if has_vt {
1641                    original_offset_to_display(&st.text, &measure_for, st.selection.start)
1642                } else {
1643                    st.selection.start
1644                };
1645                let end_off = if has_vt {
1646                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1647                } else {
1648                    st.selection.end
1649                };
1650                let sx = m
1651                    .positions
1652                    .get(byte_to_char_index(&m, start_off))
1653                    .copied()
1654                    .unwrap_or(0.0)
1655                    - st.scroll_offset;
1656                let ex = m
1657                    .positions
1658                    .get(byte_to_char_index(&m, end_off))
1659                    .copied()
1660                    .unwrap_or(sx)
1661                    - st.scroll_offset;
1662                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1663                let vis_x = sx.max(0.0);
1664                let vis_ex = ex.max(0.0);
1665                scene.nodes.push(SceneNode::Rect {
1666                    rect: repose_core::Rect {
1667                        x: rect.x + vis_x,
1668                        y: rect.y + text_off_y,
1669                        w: (vis_ex - vis_x).max(0.0),
1670                        h: line_h.max(font_val),
1671                    },
1672                    brush: Brush::Solid(selection),
1673                    radius: [0.0; 4],
1674                });
1675            }
1676
1677            // IME composition underline (visual feedback for an active preedit).
1678            if let Some(comp) = st.composition.clone() {
1679                let cs = if has_vt {
1680                    original_offset_to_display(&st.text, &measure_for, comp.start)
1681                } else {
1682                    comp.start
1683                };
1684                let ce = if has_vt {
1685                    original_offset_to_display(&st.text, &measure_for, comp.end)
1686                } else {
1687                    comp.end
1688                };
1689                let sx = m
1690                    .positions
1691                    .get(byte_to_char_index(&m, cs))
1692                    .copied()
1693                    .unwrap_or(0.0)
1694                    - st.scroll_offset;
1695                let ex = m
1696                    .positions
1697                    .get(byte_to_char_index(&m, ce))
1698                    .copied()
1699                    .unwrap_or(sx)
1700                    - st.scroll_offset;
1701                let y = rect.y + text_off_y + line_h.max(font_val) - dp_to_px(2.0);
1702                scene.nodes.push(SceneNode::Rect {
1703                    rect: repose_core::Rect {
1704                        x: rect.x + sx.max(0.0),
1705                        y,
1706                        w: (ex - sx).max(dp_to_px(2.0)),
1707                        h: dp_to_px(2.0),
1708                    },
1709                    brush: Brush::Solid(th.focus),
1710                    radius: [0.0; 4],
1711                });
1712            }
1713
1714            // Text
1715            let display_src = if st.text.is_empty() && !text_input.value.is_empty() {
1716                text_input.value.as_str()
1717            } else {
1718                st.text.as_str()
1719            };
1720            let txt_col = if display_src.is_empty() {
1721                ts.color.unwrap_or(th.on_surface_variant)
1722            } else {
1723                ts.color.unwrap_or(th.on_surface)
1724            };
1725            let render_txt = if display_src.is_empty() {
1726                text_input.hint.clone()
1727            } else {
1728                rendered_by_vt(display_src)
1729            };
1730            scene.nodes.push(SceneNode::Text {
1731                rect: repose_core::Rect {
1732                    x: rect.x - st.scroll_offset,
1733                    y: rect.y + text_off_y,
1734                    w: rect.w,
1735                    h: line_h,
1736                },
1737                text: Arc::from(render_txt),
1738                color: mul_alpha_color(txt_col, alpha_accum),
1739                size: font_val,
1740                font_family: ts.font_family,
1741                text_align: ts.text_align,
1742                font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1743                font_style: match ts.font_style.unwrap_or(0) {
1744                    1 => FontStyle::Italic,
1745                    _ => FontStyle::Normal,
1746                },
1747                text_decoration: ts.text_decoration.unwrap_or_default(),
1748                letter_spacing: ts.letter_spacing,
1749                line_height: ts.line_height,
1750                extra_style: Default::default(),
1751                url: None,
1752                font_variation_settings: None,
1753            });
1754
1755            // Caret (only when enabled && !readOnly)
1756            if show_cursor
1757                && is_focused
1758                && st.selection.start == st.selection.end
1759                && st.caret_visible()
1760            {
1761                let caret_off = if has_vt {
1762                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1763                } else {
1764                    st.selection.end
1765                };
1766                let cx = m
1767                    .positions
1768                    .get(byte_to_char_index(&m, caret_off))
1769                    .copied()
1770                    .unwrap_or(0.0)
1771                    - st.scroll_offset;
1772                let cursor_y = rect.y + text_off_y + (line_h.max(font_val) - font_val) / 2.0;
1773                scene.nodes.push(SceneNode::Rect {
1774                    rect: repose_core::Rect {
1775                        x: rect.x + cx.max(0.0),
1776                        y: cursor_y,
1777                        w: dp_to_px(1.0),
1778                        h: font_val,
1779                    },
1780                    brush: Brush::Solid(cursor_color),
1781                    radius: [0.0; 4],
1782                });
1783            }
1784        } else {
1785            // Multi-line
1786            let render_text = if st.text.is_empty() {
1787                st.text.clone()
1788            } else if let Some(ref vt) = text_input.visual_transformation {
1789                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
1790                vt.filter(&annotated).text.text
1791            } else {
1792                st.text.clone()
1793            };
1794            let layout = layout_text_area(
1795                &render_text,
1796                font_val,
1797                rect.w.max(1.0),
1798                400,
1799                0,
1800                ts.letter_spacing,
1801                None,
1802            );
1803            let lh = layout.line_h_px;
1804            let max_line_count = text_input.max_lines.unwrap_or(usize::MAX);
1805
1806            // Hint text (empty field)
1807            if st.text.is_empty() {
1808                scene.nodes.push(SceneNode::Text {
1809                    rect: repose_core::Rect {
1810                        x: rect.x,
1811                        y: rect.y,
1812                        w: rect.w,
1813                        h: line_h,
1814                    },
1815                    text: Arc::from(text_input.hint.clone()),
1816                    color: mul_alpha_color(ts.color.unwrap_or(th.on_surface_variant), alpha_accum),
1817                    size: font_val,
1818                    font_family: ts.font_family,
1819                    text_align: ts.text_align,
1820                    font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1821                    font_style: match ts.font_style.unwrap_or(0) {
1822                        1 => FontStyle::Italic,
1823                        _ => FontStyle::Normal,
1824                    },
1825                    text_decoration: ts.text_decoration.unwrap_or_default(),
1826                    letter_spacing: ts.letter_spacing,
1827                    line_height: ts.line_height,
1828                    extra_style: Default::default(),
1829                    url: None,
1830                    font_variation_settings: None,
1831                });
1832            } else {
1833                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1834                    if i >= max_line_count {
1835                        break;
1836                    }
1837                    let ln = render_text[s..e].to_string();
1838                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
1839                    if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
1840                        continue;
1841                    }
1842                    scene.nodes.push(SceneNode::Text {
1843                        rect: repose_core::Rect {
1844                            x: rect.x,
1845                            y: draw_y,
1846                            w: rect.w,
1847                            h: lh,
1848                        },
1849                        text: Arc::<str>::from(ln),
1850                        color: mul_alpha_color(ts.color.unwrap_or(th.on_surface), alpha_accum),
1851                        size: font_val,
1852                        font_family: ts.font_family,
1853                        text_align: ts.text_align,
1854                        font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1855                        font_style: match ts.font_style.unwrap_or(0) {
1856                            1 => FontStyle::Italic,
1857                            _ => FontStyle::Normal,
1858                        },
1859                        text_decoration: ts.text_decoration.unwrap_or_default(),
1860                        letter_spacing: ts.letter_spacing,
1861                        line_height: ts.line_height,
1862                        extra_style: Default::default(),
1863                        url: None,
1864                        font_variation_settings: None,
1865                    });
1866                }
1867            }
1868
1869            // Selection (multi-line)
1870            if show_selection && st.selection.start != st.selection.end {
1871                let sel_a_orig: usize = st.selection.start.min(st.selection.end);
1872                let sel_b_orig: usize = st.selection.start.max(st.selection.end);
1873                let has_vt = text_input.visual_transformation.is_some();
1874                let sel_a = if has_vt {
1875                    original_offset_to_display(&st.text, &render_text, sel_a_orig)
1876                } else {
1877                    sel_a_orig
1878                };
1879                let sel_b = if has_vt {
1880                    original_offset_to_display(&st.text, &render_text, sel_b_orig)
1881                } else {
1882                    sel_b_orig
1883                };
1884                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1885                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1886                    if i >= max_line_count {
1887                        break;
1888                    }
1889                    let os = sel_a.max(s);
1890                    let oe = sel_b.min(e);
1891                    if os >= oe {
1892                        continue;
1893                    }
1894                    let ln = &render_text[s..e];
1895                    let m = measure_text(
1896                        ln,
1897                        font_val,
1898                        TextMeasureConfig {
1899                            font_family: ts.font_family,
1900                            font_weight: ts.font_weight.unwrap_or(400),
1901                            font_style: ts.font_style.unwrap_or(0),
1902                            letter_spacing: ts.letter_spacing,
1903                            font_variation_settings: None,
1904                        },
1905                    );
1906                    let ls = os - s;
1907                    let le = oe - s;
1908                    let sx = m
1909                        .positions
1910                        .get(byte_to_char_index(&m, ls))
1911                        .copied()
1912                        .unwrap_or(0.0);
1913                    let ex = m
1914                        .positions
1915                        .get(byte_to_char_index(&m, le))
1916                        .copied()
1917                        .unwrap_or(sx);
1918                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
1919                    scene.nodes.push(SceneNode::Rect {
1920                        rect: repose_core::Rect {
1921                            x: rect.x + sx,
1922                            y: draw_y,
1923                            w: (ex - sx).max(0.0),
1924                            h: lh,
1925                        },
1926                        brush: Brush::Solid(selection),
1927                        radius: [0.0; 4],
1928                    });
1929                }
1930            }
1931
1932            // IME composition underline (multi-line): intersect the preedit
1933            // range with each visible line and underline the overlapping span.
1934            if let Some(comp) = st.composition.clone() {
1935                let has_vt = text_input.visual_transformation.is_some();
1936                let comp_a = if has_vt {
1937                    original_offset_to_display(&st.text, &render_text, comp.start)
1938                } else {
1939                    comp.start
1940                };
1941                let comp_b = if has_vt {
1942                    original_offset_to_display(&st.text, &render_text, comp.end)
1943                } else {
1944                    comp.end
1945                };
1946                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1947                    if i >= max_line_count {
1948                        break;
1949                    }
1950                    let os = comp_a.max(s);
1951                    let oe = comp_b.min(e);
1952                    if os >= oe {
1953                        continue;
1954                    }
1955                    let ln = &render_text[s..e];
1956                    let m = measure_text(
1957                        ln,
1958                        font_val,
1959                        TextMeasureConfig {
1960                            font_family: ts.font_family,
1961                            font_weight: ts.font_weight.unwrap_or(400),
1962                            font_style: ts.font_style.unwrap_or(0),
1963                            letter_spacing: ts.letter_spacing,
1964                            font_variation_settings: None,
1965                        },
1966                    );
1967                    let ls = os - s;
1968                    let le = oe - s;
1969                    let sx = m
1970                        .positions
1971                        .get(byte_to_char_index(&m, ls))
1972                        .copied()
1973                        .unwrap_or(0.0);
1974                    let ex = m
1975                        .positions
1976                        .get(byte_to_char_index(&m, le))
1977                        .copied()
1978                        .unwrap_or(sx);
1979                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
1980                    scene.nodes.push(SceneNode::Rect {
1981                        rect: repose_core::Rect {
1982                            x: rect.x + sx,
1983                            y: draw_y + lh - dp_to_px(2.0),
1984                            w: (ex - sx).max(dp_to_px(2.0)),
1985                            h: dp_to_px(2.0),
1986                        },
1987                        brush: Brush::Solid(th.focus),
1988                        radius: [0.0; 4],
1989                    });
1990                }
1991            }
1992
1993            // Caret (multi-line) - only when enabled && !readOnly
1994            if show_cursor
1995                && is_focused
1996                && st.selection.start == st.selection.end
1997                && st.caret_visible()
1998            {
1999                let caret_orig = st.selection.end.min(st.text.len());
2000                let has_vt = text_input.visual_transformation.is_some();
2001                let caret = if has_vt {
2002                    original_offset_to_display(&st.text, &render_text, caret_orig)
2003                } else {
2004                    caret_orig
2005                };
2006                let (cx, cy, _li) =
2007                    caret_xy_for_byte(&render_text, font_val, rect.w.max(1.0), caret);
2008                let draw_x = rect.x + cx;
2009                let draw_y = rect.y + cy - st.scroll_offset_y;
2010                scene.nodes.push(SceneNode::Rect {
2011                    rect: repose_core::Rect {
2012                        x: draw_x,
2013                        y: draw_y + (lh - font_val) / 2.0,
2014                        w: dp_to_px(1.0),
2015                        h: font_val,
2016                    },
2017                    brush: Brush::Solid(cursor_color),
2018                    radius: [0.0; 4],
2019                });
2020            }
2021        }
2022    } else {
2023        // No state yet (unfocused) - render hint or raw value
2024        if text_input.value.is_empty() {
2025            let hint_y = if text_input.multiline {
2026                rect.y
2027            } else {
2028                rect.y + text_off_y
2029            };
2030            scene.nodes.push(SceneNode::Text {
2031                rect: repose_core::Rect {
2032                    x: rect.x,
2033                    y: hint_y,
2034                    w: rect.w,
2035                    h: line_h,
2036                },
2037                text: Arc::from(text_input.hint.clone()),
2038                color: mul_alpha_color(th.on_surface_variant, alpha_accum),
2039                size: font_val,
2040                font_family: None,
2041                text_align: TextAlign::Unspecified,
2042                font_weight: FontWeight::NORMAL,
2043                font_style: FontStyle::Normal,
2044                text_decoration: ts.text_decoration.unwrap_or_default(),
2045                letter_spacing: 0.0,
2046                line_height: 0.0,
2047                extra_style: Default::default(),
2048                url: None,
2049                font_variation_settings: None,
2050            });
2051        } else if text_input.multiline {
2052            let render_text = if text_input.value.is_empty() {
2053                text_input.value.clone()
2054            } else if let Some(ref vt) = text_input.visual_transformation {
2055                let annotated = repose_core::AnnotatedString::new(text_input.value.clone(), vec![]);
2056                vt.filter(&annotated).text.text
2057            } else {
2058                text_input.value.clone()
2059            };
2060            let layout = layout_text_area(
2061                &render_text,
2062                font_val,
2063                rect.w.max(1.0),
2064                400,
2065                0,
2066                ts.letter_spacing,
2067                None,
2068            );
2069            let lh = layout.line_h_px;
2070            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
2071                let ln = render_text[s..e].to_string();
2072                let draw_y = rect.y + (i as f32) * lh;
2073                if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
2074                    continue;
2075                }
2076                scene.nodes.push(SceneNode::Text {
2077                    rect: repose_core::Rect {
2078                        x: rect.x,
2079                        y: draw_y,
2080                        w: rect.w,
2081                        h: lh,
2082                    },
2083                    text: Arc::<str>::from(ln),
2084                    color: mul_alpha_color(th.on_surface, alpha_accum),
2085                    size: font_val,
2086                    font_family: None,
2087                    text_align: TextAlign::Unspecified,
2088                    font_weight: FontWeight::NORMAL,
2089                    font_style: FontStyle::Normal,
2090                    text_decoration: ts.text_decoration.unwrap_or_default(),
2091                    letter_spacing: 0.0,
2092                    line_height: 0.0,
2093                    extra_style: Default::default(),
2094                    url: None,
2095                    font_variation_settings: None,
2096                });
2097            }
2098        } else {
2099            scene.nodes.push(SceneNode::Text {
2100                rect: repose_core::Rect {
2101                    x: rect.x,
2102                    y: rect.y + text_off_y,
2103                    w: rect.w,
2104                    h: line_h,
2105                },
2106                text: Arc::from(rendered_by_vt(&text_input.value)),
2107                color: mul_alpha_color(th.on_surface, alpha_accum),
2108                size: font_val,
2109                font_family: None,
2110                text_align: TextAlign::Unspecified,
2111                font_weight: FontWeight::NORMAL,
2112                font_style: FontStyle::Normal,
2113                text_decoration: ts.text_decoration.unwrap_or_default(),
2114                letter_spacing: 0.0,
2115                line_height: 0.0,
2116                extra_style: Default::default(),
2117                url: None,
2118                font_variation_settings: None,
2119            });
2120        }
2121    }
2122
2123    // Fire on_text_layout callback with computed layout info
2124    if let Some(ref cb) = text_input.on_text_layout {
2125        let (
2126            line_count,
2127            content_w,
2128            content_h,
2129            first_baseline,
2130            last_baseline,
2131            did_overflow_w,
2132            did_overflow_h,
2133            lines,
2134        ) = if let Some(state_rc) = state {
2135            let st = state_rc.borrow();
2136            let display = if st.text.is_empty() {
2137                text_input.hint.clone()
2138            } else if let Some(ref vt) = text_input.visual_transformation {
2139                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
2140                vt.filter(&annotated).text.text
2141            } else {
2142                st.text.clone()
2143            };
2144            if text_input.multiline {
2145                let l = layout_text_area(
2146                    &display,
2147                    font_val,
2148                    rect.w.max(1.0),
2149                    400,
2150                    0,
2151                    ts.letter_spacing,
2152                    None,
2153                );
2154                let lc = l.ranges.len();
2155                let cw = rect.w.max(0.0);
2156                let ch = (lc as f32 * l.line_h_px).max(0.0);
2157                let line_infos: Vec<_> = l
2158                    .ranges
2159                    .iter()
2160                    .enumerate()
2161                    .map(|(i, &(s, e))| {
2162                        let top = i as f32 * l.line_h_px;
2163                        let bottom = top + l.line_h_px;
2164                        let line_text = &display[s..e];
2165                        let m = measure_text(line_text, font_val, TextMeasureConfig::default());
2166                        let line_w = m.positions.last().copied().unwrap_or(0.0);
2167                        TextLineInfo {
2168                            start: s,
2169                            end: e,
2170                            top,
2171                            baseline: top + l.line_h_px * 0.8,
2172                            bottom,
2173                            left: 0.0,
2174                            right: line_w,
2175                            width: line_w,
2176                        }
2177                    })
2178                    .collect();
2179                let fb = line_infos.first().map(|l| l.baseline).unwrap_or(0.0);
2180                let lb = line_infos.last().map(|l| l.baseline).unwrap_or(0.0);
2181                (lc, cw, ch, fb, lb, cw > rect.w, ch > rect.h, line_infos)
2182            } else {
2183                let m = measure_text(&display, font_val, TextMeasureConfig::default());
2184                let w = m.positions.last().copied().unwrap_or(0.0);
2185                let top = 0.0;
2186                let bottom = line_h.max(font_val);
2187                let baseline = bottom * 0.8;
2188                let line_info = TextLineInfo {
2189                    start: 0,
2190                    end: display.len(),
2191                    top,
2192                    baseline,
2193                    bottom,
2194                    left: 0.0,
2195                    right: w,
2196                    width: w,
2197                };
2198                (
2199                    1,
2200                    w.max(0.0),
2201                    bottom,
2202                    baseline,
2203                    baseline,
2204                    w > rect.w,
2205                    bottom > rect.h,
2206                    vec![line_info],
2207                )
2208            }
2209        } else {
2210            (0, 0.0, 0.0, 0.0, 0.0, false, false, vec![])
2211        };
2212        cb(&repose_core::TextLayoutResult {
2213            line_count,
2214            width_px: content_w,
2215            height_px: content_h,
2216            first_baseline,
2217            last_baseline,
2218            did_overflow_width: did_overflow_w,
2219            did_overflow_height: did_overflow_h,
2220            lines,
2221        });
2222    }
2223
2224    scene.nodes.push(SceneNode::PopClip);
2225}
2226
2227/// Shared view-builder for `BasicTextField`.
2228/// Creates the view with text_input modifier. Painting is handled natively
2229/// by layout.rs when it encounters `modifier.text_input` (Compose-aligned).
2230fn text_field_view(
2231    modifier: Modifier,
2232    hint: String,
2233    value: String,
2234    multiline: bool,
2235    on_change: Option<Rc<dyn Fn(String)>>,
2236    on_submit: Option<Rc<dyn Fn(String)>>,
2237    visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
2238    keyboard_type: repose_core::KeyboardType,
2239    capitalization: repose_core::KeyboardCapitalization,
2240    ime_action: repose_core::ImeAction,
2241    auto_correct_enabled: Option<bool>,
2242    enabled: bool,
2243    read_only: bool,
2244    max_lines: Option<usize>,
2245    min_lines: usize,
2246    cursor_color: Option<Color>,
2247    on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
2248    text_style: repose_core::TextStyle,
2249    keyboard_actions: repose_core::KeyboardActions,
2250    interaction_source: Option<repose_core::MutableInteractionSource>,
2251    focus_tracker: Option<Rc<Cell<bool>>>,
2252    line_limits: Option<repose_core::TextFieldLineLimits>,
2253    _input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
2254    _output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
2255    decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
2256    _codepoint_transformation: Option<repose_core::CodepointTransformation>,
2257) -> View {
2258    let mut modif = modifier.text_input(TextInputConfig {
2259        hint,
2260        multiline,
2261        on_change,
2262        on_submit,
2263        focus_tracker,
2264        value,
2265        visual_transformation,
2266        keyboard_type,
2267        capitalization,
2268        ime_action,
2269        auto_correct_enabled,
2270        enabled,
2271        read_only,
2272        max_lines,
2273        min_lines,
2274        cursor_color,
2275        on_text_layout,
2276        text_style: Some(text_style),
2277        keyboard_actions: Some(keyboard_actions),
2278        interaction_source: interaction_source.as_ref().map(|s| s.source()),
2279        line_limits,
2280    });
2281
2282    // `enabled=false` => not focusable and inert;
2283    // `read_only` keeps focus for selection/copy but blocks mutation.
2284    if !enabled {
2285        modif = modif.focusable(false).enabled(false);
2286    } else if read_only {
2287        modif = modif.focusable(true);
2288    }
2289
2290    let inner = View::new(0, ViewKind::Box)
2291        .modifier(modif)
2292        .semantics(Semantics {
2293            role: Role::TextField,
2294            enabled,
2295            ..Default::default()
2296        });
2297
2298    // Compose `decorationBox`: wrap the field node when provided.
2299    if let Some(decorate) = decoration_box {
2300        decorate(inner)
2301    } else {
2302        inner
2303    }
2304}
2305
2306#[cfg(test)]
2307mod tests {
2308    use super::*;
2309
2310    #[test]
2311    fn test_index_for_x_bytes_grapheme() {
2312        let t = "A👍🏽B";
2313        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
2314        let m = measure_text(t, font_px, TextMeasureConfig::default());
2315        for i in 0..m.byte_offsets.len() - 1 {
2316            let b = m.byte_offsets[i];
2317            let _ = &t[..b];
2318        }
2319    }
2320
2321    fn delete_op(
2322        index: usize,
2323        pre_text: &str,
2324        pre_selection: Range<usize>,
2325        post_selection: Range<usize>,
2326    ) -> TextUndoOp {
2327        TextUndoOp {
2328            index,
2329            pre_text: pre_text.to_string(),
2330            post_text: String::new(),
2331            pre_selection,
2332            post_selection,
2333            time: Instant::now(),
2334            can_merge: true,
2335        }
2336    }
2337
2338    #[test]
2339    fn deletion_type_collapsed_post_selection_is_backspace() {
2340        // Backspace on "abc" with cursor at 3 deletes 'c', cursor moves to 2.
2341        let op = delete_op(2, "c", 3..3, 2..2);
2342        assert_eq!(op.deletion_type(), TextDeleteType::Start);
2343    }
2344
2345    #[test]
2346    fn deletion_type_collapsed_post_selection_is_delete_forward() {
2347        // Delete-forward at cursor 3 removes 'c' but the cursor stays put.
2348        let op = delete_op(3, "c", 3..3, 3..3);
2349        assert_eq!(op.deletion_type(), TextDeleteType::End);
2350    }
2351
2352    #[test]
2353    fn deletion_type_range_post_selection_is_not_by_user() {
2354        // A deletion that leaves an expanded post-selection is not a plain
2355        // backspace/delete-forward and must never merge (regression for the
2356        // old `!start == end` precedence bug which compared bitwise-not of start).
2357        let op = delete_op(2, "de", 2..4, 3..5);
2358        assert_eq!(op.deletion_type(), TextDeleteType::NotByUser);
2359    }
2360
2361    #[test]
2362    fn backspace_ops_merge() {
2363        // "abc": cursor 3 -> 2 -> 1 via two backspaces merges into one "bc" delete.
2364        let a = delete_op(2, "c", 3..3, 2..2);
2365        let b = delete_op(1, "b", 2..2, 1..1);
2366        let merged = a
2367            .try_merge(&b)
2368            .expect("consecutive backspaces should merge");
2369        assert_eq!(merged.index, 1);
2370        assert_eq!(merged.pre_text, "bc");
2371    }
2372
2373    #[test]
2374    fn selection_delete_does_not_merge_with_backspace() {
2375        // Selection-delete classifies as Inner, so it never merges with a
2376        // Start/End backspace-merge even back-to-back.
2377        let backspace = delete_op(2, "c", 3..3, 2..2);
2378        let selection = delete_op(2, "de", 2..4, 2..2);
2379        assert_eq!(selection.deletion_type(), TextDeleteType::Inner);
2380        assert!(backspace.try_merge(&selection).is_none());
2381    }
2382}