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