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