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, Weak};
45use std::sync::Arc;
46use std::sync::atomic::{AtomicU64, Ordering};
47use unicode_segmentation::UnicodeSegmentation;
48use web_time::Duration;
49use web_time::Instant;
50
51use crate::layout::mul_alpha_color;
52use crate::text_selection::{
53    SELECTION_BACKGROUND_ALPHA, SelectionLine, push_selection, selection_brush, selection_rects,
54};
55
56static NEXT_TEXTFIELD_TRANSFORM_ID: AtomicU64 = AtomicU64::new(1);
57
58thread_local! {
59    static TEXTFIELD_STATES: RefCell<HashMap<u64, Weak<RefCell<TextFieldState>>>> =
60        RefCell::new(HashMap::new());
61    static TEXTFIELD_TRANSFORMS: RefCell<HashMap<u64, TextFieldTransformEntry>> =
62        RefCell::new(HashMap::new());
63    static TEXTFIELD_TRANSFORM_MARKERS: RefCell<HashMap<u64, u64>> =
64        RefCell::new(HashMap::new());
65}
66
67#[derive(Clone)]
68struct TextFieldTransforms {
69    visual: Option<Rc<dyn VisualTransformation>>,
70    input: Option<Rc<dyn InputTransformation>>,
71    output: Option<Rc<dyn OutputTransformation>>,
72    codepoint: Option<Rc<CodepointTransformation>>,
73}
74
75struct TextFieldTransformEntry {
76    transforms: TextFieldTransforms,
77    marker: Weak<TextFieldTransformMarker>,
78}
79
80#[derive(Debug)]
81struct TextFieldTransformMarker(u64);
82
83impl VisualTransformation for TextFieldTransformMarker {
84    fn filter(&self, text: &AnnotatedString) -> TransformedText {
85        let transforms = TEXTFIELD_TRANSFORMS.with(|all| {
86            all.borrow()
87                .get(&self.0)
88                .map(|entry| entry.transforms.clone())
89        });
90        let Some(transforms) = transforms else {
91            return TransformedText::new(text.clone(), Box::new(IdentityOffsetMapping));
92        };
93        let mut transformed = transforms.visual.as_ref().map_or_else(
94            || TransformedText::new(text.clone(), Box::new(IdentityOffsetMapping)),
95            |visual| visual.filter(text),
96        );
97        if let Some(output) = &transforms.output {
98            let before = transformed.text.text.clone();
99            let mut buffer = TransformationBuffer::new(before.clone(), 0..before.len());
100            output.transform_output(&mut buffer);
101            let after = buffer.text;
102            let stage = CodepointOffsetMapping::new(&before, &after);
103            transformed.offset_mapping = Box::new(ComposedOffsetMapping {
104                first: transformed.offset_mapping,
105                second: Box::new(stage),
106            });
107            transformed.text.text = after;
108        }
109        if let Some(codepoint) = &transforms.codepoint {
110            let before = transformed.text.text.clone();
111            let after = before
112                .chars()
113                .enumerate()
114                .map(|(index, ch)| codepoint.transform(index, ch))
115                .collect::<String>();
116            let stage = CodepointOffsetMapping::new(&before, &after);
117            transformed.offset_mapping = Box::new(ComposedOffsetMapping {
118                first: transformed.offset_mapping,
119                second: Box::new(stage),
120            });
121            transformed.text.text = after;
122        }
123        transformed
124    }
125}
126
127pub fn set_textfield_state(key: u64, state: Rc<RefCell<TextFieldState>>) {
128    TEXTFIELD_STATES.with(|states| {
129        states.borrow_mut().insert(key, Rc::downgrade(&state));
130    });
131}
132
133pub fn get_textfield_state(key: u64) -> Option<Rc<RefCell<TextFieldState>>> {
134    TEXTFIELD_STATES.with(|states| states.borrow().get(&key).and_then(Weak::upgrade))
135}
136
137pub(crate) fn prune_textfield_registries() {
138    TEXTFIELD_STATES.with(|states| {
139        states
140            .borrow_mut()
141            .retain(|_, state| state.strong_count() != 0);
142    });
143    let mut live = std::collections::HashSet::new();
144    TEXTFIELD_TRANSFORMS.with(|transforms| {
145        transforms.borrow_mut().retain(|id, entry| {
146            let keep = entry.marker.strong_count() != 0;
147            if keep {
148                live.insert(*id);
149            }
150            keep
151        });
152    });
153    TEXTFIELD_TRANSFORM_MARKERS.with(|markers| {
154        markers.borrow_mut().retain(|_, id| live.contains(id));
155    });
156}
157
158pub fn ensure_caret_visible(state: &mut TextFieldState, multiline: bool) {
159    let metrics = TextFieldMetrics::default();
160    ensure_caret_visible_with_metrics(state, multiline, &metrics);
161}
162
163pub fn ensure_caret_visible_with_metrics(
164    state: &mut TextFieldState,
165    multiline: bool,
166    metrics: &TextFieldMetrics,
167) {
168    let caret = state.caret_index();
169    let (display, display_caret) = if let Some(transformation) = &state.visual_transformation {
170        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
171        let transformed = transformation.filter(&annotated);
172        let offset = transformed.offset_mapping.original_to_transformed(caret);
173        (transformed.text.text, offset)
174    } else {
175        (state.text.clone(), caret)
176    };
177    let wrap_width = state.inner_width.max(1.0);
178    if multiline {
179        let (cx, cy, _) =
180            caret_xy_for_byte_with_metrics(&display, wrap_width, display_caret, metrics);
181        state.ensure_caret_visible_xy(
182            cx,
183            cy,
184            state.inner_width,
185            state.inner_height,
186            Dp(2.0).to_px().0,
187        );
188    } else {
189        let measured = measure_text(&display, metrics.font_px, metrics.measure_config());
190        let caret_x = measured
191            .positions
192            .get(byte_to_char_index(&measured, display_caret))
193            .copied()
194            .unwrap_or(0.0);
195        state.ensure_caret_visible(caret_x, wrap_width, Dp(2.0).to_px().0);
196    }
197}
198
199/// Maximum number of undo/redo operations stored in history.
200const TEXT_UNDO_CAPACITY: usize = 100;
201
202/// Time window (ms) within which consecutive operations can be merged.
203const SNAPSHOTS_INTERVAL_MILLIS: u128 = 5000;
204
205/// Type of text edit operation.
206#[derive(Clone, Copy, Debug, PartialEq)]
207enum TextEditType {
208    Insert,
209    Delete,
210    Replace,
211}
212
213/// Direction of a deletion.
214#[derive(Clone, Copy, Debug, PartialEq)]
215enum TextDeleteType {
216    Start, // backspace: cursor moving towards start
217    End,   // delete forward: cursor moving towards end
218    Inner, // selection removed
219    NotByUser,
220}
221
222/// A single atomic text change that can be undone/redone.
223#[derive(Clone, Debug)]
224pub struct TextUndoOp {
225    /// Start point of the change in the text.
226    pub index: usize,
227    /// Text that was present before the change (being replaced/deleted).
228    pub pre_text: String,
229    /// Text that was inserted (replacing pre_text).
230    pub post_text: String,
231    /// Selection before the change.
232    pub pre_selection: Range<usize>,
233    /// Selection after the change.
234    pub post_selection: Range<usize>,
235    /// When this change was first committed.
236    pub time: Instant,
237    /// Whether this change can merge with adjacent operations.
238    pub can_merge: bool,
239}
240
241impl TextUndoOp {
242    fn edit_type(&self) -> TextEditType {
243        match (self.pre_text.is_empty(), self.post_text.is_empty()) {
244            (true, true) => unreachable!("Both pre and post text cannot be empty"),
245            (true, false) => TextEditType::Insert,
246            (false, true) => TextEditType::Delete,
247            (false, false) => TextEditType::Replace,
248        }
249    }
250
251    fn is_newline(&self) -> bool {
252        self.post_text == "\n" || self.post_text == "\r\n"
253    }
254
255    /// Try to merge `self` (earlier) with `next` (later). Returns merged op if merge is possible.
256    fn try_merge(&self, next: &TextUndoOp) -> Option<TextUndoOp> {
257        if !self.can_merge || !next.can_merge {
258            return None;
259        }
260
261        let elapsed = next.time.saturating_duration_since(self.time);
262        if elapsed.as_millis() >= SNAPSHOTS_INTERVAL_MILLIS {
263            return None;
264        }
265
266        if self.is_newline() || next.is_newline() {
267            return None;
268        }
269
270        let self_type = self.edit_type();
271        if self_type != next.edit_type() {
272            return None;
273        }
274
275        match self_type {
276            TextEditType::Insert => {
277                // Only merge if next insertion continues from the end of this one
278                if self.index + self.post_text.len() == next.index {
279                    Some(TextUndoOp {
280                        index: self.index,
281                        pre_text: String::new(),
282                        post_text: format!("{}{}", self.post_text, next.post_text),
283                        pre_selection: self.pre_selection.clone(),
284                        post_selection: next.post_selection.clone(),
285                        time: self.time,
286                        can_merge: true,
287                    })
288                } else {
289                    None
290                }
291            }
292            TextEditType::Delete => {
293                let self_del = self.deletion_type();
294                let next_del = next.deletion_type();
295                // Only merge consecutive deletions with same directionality
296                if self_del == next_del
297                    && (self_del == TextDeleteType::Start || self_del == TextDeleteType::End)
298                {
299                    if self.index == next.index + next.pre_text.len() {
300                        // This op is after next (backspace: deleting right-to-left)
301                        Some(TextUndoOp {
302                            index: next.index,
303                            pre_text: format!("{}{}", next.pre_text, self.pre_text),
304                            post_text: String::new(),
305                            pre_selection: self.pre_selection.clone(),
306                            post_selection: next.post_selection.clone(),
307                            time: self.time,
308                            can_merge: true,
309                        })
310                    } else if self.index == next.index {
311                        // Same position (delete forward: deleting left-to-right)
312                        Some(TextUndoOp {
313                            index: self.index,
314                            pre_text: format!("{}{}", self.pre_text, next.pre_text),
315                            post_text: String::new(),
316                            pre_selection: self.pre_selection.clone(),
317                            post_selection: next.post_selection.clone(),
318                            time: self.time,
319                            can_merge: true,
320                        })
321                    } else {
322                        None
323                    }
324                } else {
325                    None
326                }
327            }
328            TextEditType::Replace => None,
329        }
330    }
331
332    /// Determine the deletion direction. Only meaningful when edit_type is Delete.
333    fn deletion_type(&self) -> TextDeleteType {
334        if self.edit_type() != TextEditType::Delete {
335            return TextDeleteType::NotByUser;
336        }
337        if self.post_selection.start != self.post_selection.end {
338            return TextDeleteType::NotByUser;
339        }
340        if self.pre_selection.start == self.pre_selection.end {
341            // Collapsed selection before delete: cursor moved
342            if self.pre_selection.start > self.post_selection.start {
343                TextDeleteType::Start // backspace
344            } else {
345                TextDeleteType::End // delete forward
346            }
347        } else if self.pre_selection.start == self.post_selection.start
348            && self.pre_selection.start == self.index
349        {
350            TextDeleteType::Inner
351        } else {
352            TextDeleteType::NotByUser
353        }
354    }
355}
356
357/// Spring physics constants for smooth scroll animation.
358const SCROLL_STIFFNESS: f32 = 300.0;
359const SCROLL_DAMPING: f32 = 30.0;
360
361/// Logical font size for TextField in [`Sp`] (converted to px at
362/// measure/paint time, including `TextScale`).
363pub const TF_FONT_SP: Sp = Sp(16.0);
364
365/// Configures the keyboard for a text field.
366#[derive(Clone, Copy, Debug)]
367pub struct KeyboardOptions {
368    pub keyboard_type: repose_core::KeyboardType,
369    pub autocorrect: bool,
370    pub capitalization: repose_core::KeyboardCapitalization,
371}
372
373impl Default for KeyboardOptions {
374    fn default() -> Self {
375        Self {
376            keyboard_type: repose_core::KeyboardType::default(),
377            autocorrect: true,
378            capitalization: repose_core::KeyboardCapitalization::default(),
379        }
380    }
381}
382/// Horizontal padding inside the TextField in [`Dp`].
383pub const TF_PADDING_X: Dp = Dp(8.0);
384
385pub struct TextMetrics {
386    /// positions[i] = advance up to the i-th grapheme (len == graphemes + 1)
387    pub positions: Vec<f32>, // px
388    /// byte_offsets[i] = byte index of the i-th grapheme (last == text.len())
389    pub byte_offsets: Vec<usize>,
390}
391
392#[derive(Clone)]
393pub struct TextMeasureConfig {
394    pub font_family: Option<&'static str>,
395    pub font_weight: u16,
396    pub font_style: u8,
397    pub letter_spacing: f32,
398    pub font_variation_settings: Option<String>,
399}
400
401impl Default for TextMeasureConfig {
402    fn default() -> Self {
403        Self {
404            font_family: None,
405            font_weight: 400,
406            font_style: 0,
407            letter_spacing: 0.0,
408            font_variation_settings: None,
409        }
410    }
411}
412
413#[derive(Clone, Debug)]
414pub struct TextFieldMetrics {
415    pub font_px: f32,
416    pub font_family: Option<&'static str>,
417    pub font_weight: u16,
418    pub font_style: u8,
419    pub letter_spacing_px: f32,
420    pub font_variation_settings: Option<String>,
421    pub line_height_px: f32,
422}
423
424impl Default for TextFieldMetrics {
425    fn default() -> Self {
426        let font_px = TF_FONT_SP.to_px().0;
427        Self {
428            font_px,
429            font_family: None,
430            font_weight: 400,
431            font_style: 0,
432            letter_spacing_px: 0.0,
433            font_variation_settings: None,
434            line_height_px: font_px,
435        }
436    }
437}
438
439impl TextFieldMetrics {
440    pub(crate) fn from_config(config: &TextInputConfig) -> Self {
441        let style = config.text_style.clone().unwrap_or_default();
442        let font_px = if style.font_size == Sp::ZERO {
443            TF_FONT_SP.to_px().0
444        } else {
445            style.font_size.to_px().0
446        };
447        Self {
448            font_px,
449            font_family: style.font_family,
450            font_weight: style.font_weight.unwrap_or(400),
451            font_style: style.font_style.unwrap_or(0),
452            letter_spacing_px: style.letter_spacing.to_px().0,
453            font_variation_settings: style.font_variation_settings.clone(),
454            line_height_px: if style.line_height == Sp::ZERO {
455                font_px
456            } else {
457                style.line_height.to_px().0
458            },
459        }
460    }
461
462    pub fn measure_config(&self) -> TextMeasureConfig {
463        TextMeasureConfig {
464            font_family: self.font_family,
465            font_weight: self.font_weight,
466            font_style: self.font_style,
467            letter_spacing: self.letter_spacing_px,
468            font_variation_settings: self.font_variation_settings.clone(),
469        }
470    }
471}
472
473struct TransformationBuffer {
474    text: String,
475    selection: Range<usize>,
476    original_text: String,
477    original_selection: Range<usize>,
478}
479
480impl TransformationBuffer {
481    fn new(text: String, selection: Range<usize>) -> Self {
482        let selection = clamp_range(&text, selection);
483        Self {
484            original_text: text.clone(),
485            original_selection: selection.clone(),
486            text,
487            selection,
488        }
489    }
490
491    fn normalized(&mut self) {
492        self.selection = clamp_range(&self.text, self.selection.clone());
493    }
494}
495
496impl TextFieldBuffer for TransformationBuffer {
497    fn text(&self) -> &str {
498        &self.text
499    }
500
501    fn set_text(&mut self, text: &str) {
502        self.text = text.to_string();
503        self.normalized();
504    }
505
506    fn selection(&self) -> TextRange {
507        TextRange::new(self.selection.start, self.selection.end)
508    }
509
510    fn set_selection(&mut self, selection: TextRange) {
511        self.selection = clamp_range(
512            &self.text,
513            selection.start.min(selection.end)..selection.start.max(selection.end),
514        );
515    }
516
517    fn length(&self) -> usize {
518        self.text.chars().count()
519    }
520
521    fn replace(&mut self, start: usize, end: usize, text: &str) {
522        let start = byte_for_char(&self.text, start);
523        let end = byte_for_char(&self.text, end);
524        self.text.replace_range(start..end.max(start), text);
525        self.selection = clamp_range(&self.text, start..start + text.len());
526    }
527
528    fn insert(&mut self, index: usize, text: &str) {
529        let start = byte_for_char(&self.text, index);
530        self.text.insert_str(start, text);
531        self.selection = clamp_range(&self.text, start..start + text.len());
532    }
533
534    fn delete(&mut self, start: usize, end: usize) {
535        let start = byte_for_char(&self.text, start);
536        let end = byte_for_char(&self.text, end);
537        self.text.replace_range(start..end.max(start), "");
538        self.selection = clamp_range(&self.text, start..start);
539    }
540
541    fn place_cursor_before_char_at(&mut self, index: usize) {
542        let start = byte_for_char(&self.text, index);
543        self.selection = clamp_range(&self.text, start..start);
544    }
545
546    fn place_cursor_at_end(&mut self) {
547        let end = self.text.len();
548        self.selection = end..end;
549    }
550
551    fn select_all(&mut self) {
552        self.selection = 0..self.text.len();
553    }
554
555    fn revert_all_changes(&mut self) {
556        self.text = self.original_text.clone();
557        self.selection = self.original_selection.clone();
558    }
559
560    fn original_text(&self) -> &str {
561        &self.original_text
562    }
563
564    fn original_selection(&self) -> TextRange {
565        TextRange::new(self.original_selection.start, self.original_selection.end)
566    }
567
568    fn has_selection(&self) -> bool {
569        self.selection.start != self.selection.end
570    }
571}
572
573fn byte_for_char(text: &str, index: usize) -> usize {
574    text.char_indices().nth(index).map_or(text.len(), |v| v.0)
575}
576
577fn clamp_range(text: &str, range: Range<usize>) -> Range<usize> {
578    let mut start = range.start.min(text.len());
579    let mut end = range.end.min(text.len());
580    while start > 0 && !text.is_char_boundary(start) {
581        start -= 1;
582    }
583    while end > 0 && !text.is_char_boundary(end) {
584        end -= 1;
585    }
586    start..end
587}
588
589#[derive(Debug)]
590struct CodepointOffsetMapping {
591    original: Vec<usize>,
592    transformed: Vec<usize>,
593}
594
595impl CodepointOffsetMapping {
596    fn new(original: &str, transformed: &str) -> Self {
597        let original_offsets: Vec<usize> = original
598            .char_indices()
599            .map(|(index, _)| index)
600            .chain(std::iter::once(original.len()))
601            .collect();
602        let transformed_offsets: Vec<usize> = transformed
603            .char_indices()
604            .map(|(index, _)| index)
605            .chain(std::iter::once(transformed.len()))
606            .collect();
607        if original_offsets.len() == transformed_offsets.len() {
608            Self {
609                original: original_offsets,
610                transformed: transformed_offsets,
611            }
612        } else {
613            Self {
614                original: vec![0, original.len()],
615                transformed: vec![0, transformed.len()],
616            }
617        }
618    }
619
620    fn map(offsets: &[usize], value: usize) -> usize {
621        let index = match offsets.binary_search(&value) {
622            Ok(index) => index,
623            Err(index) => index.saturating_sub(1),
624        };
625        offsets
626            .get(index)
627            .copied()
628            .or_else(|| offsets.last().copied())
629            .unwrap_or(0)
630    }
631}
632
633impl OffsetMapping for CodepointOffsetMapping {
634    fn original_to_transformed(&self, offset: usize) -> usize {
635        Self::map(&self.original, offset)
636    }
637
638    fn transformed_to_original(&self, offset: usize) -> usize {
639        Self::map(&self.transformed, offset)
640    }
641
642    fn clone_box(&self) -> Box<dyn OffsetMapping> {
643        Box::new(Self {
644            original: self.original.clone(),
645            transformed: self.transformed.clone(),
646        })
647    }
648}
649
650#[derive(Debug)]
651struct ComposedOffsetMapping {
652    first: Box<dyn OffsetMapping>,
653    second: Box<dyn OffsetMapping>,
654}
655
656impl OffsetMapping for ComposedOffsetMapping {
657    fn original_to_transformed(&self, offset: usize) -> usize {
658        self.second
659            .original_to_transformed(self.first.original_to_transformed(offset))
660    }
661
662    fn transformed_to_original(&self, offset: usize) -> usize {
663        self.first
664            .transformed_to_original(self.second.transformed_to_original(offset))
665    }
666
667    fn clone_box(&self) -> Box<dyn OffsetMapping> {
668        Box::new(Self {
669            first: self.first.clone_box(),
670            second: self.second.clone_box(),
671        })
672    }
673}
674
675fn install_textfield_transforms(
676    visual: Option<Rc<dyn VisualTransformation>>,
677    input: Option<Rc<dyn InputTransformation>>,
678    output: Option<Rc<dyn OutputTransformation>>,
679    codepoint: Option<CodepointTransformation>,
680) -> Option<Rc<dyn VisualTransformation>> {
681    if visual.is_none() && input.is_none() && output.is_none() && codepoint.is_none() {
682        return None;
683    }
684    let id = NEXT_TEXTFIELD_TRANSFORM_ID.fetch_add(1, Ordering::Relaxed);
685    let marker = Rc::new(TextFieldTransformMarker(id));
686    let pointer = Rc::as_ptr(&marker) as u64;
687    let visual_marker: Rc<dyn VisualTransformation> = marker.clone();
688    TEXTFIELD_TRANSFORMS.with(|all| {
689        all.borrow_mut().insert(
690            id,
691            TextFieldTransformEntry {
692                transforms: TextFieldTransforms {
693                    visual,
694                    input,
695                    output,
696                    codepoint: codepoint.map(Rc::new),
697                },
698                marker: Rc::downgrade(&marker),
699            },
700        );
701    });
702    TEXTFIELD_TRANSFORM_MARKERS.with(|markers| {
703        markers.borrow_mut().insert(pointer, id);
704    });
705    Some(visual_marker)
706}
707
708pub(crate) fn textfield_transform_id(transformation: &Rc<dyn VisualTransformation>) -> Option<u64> {
709    let pointer = Rc::as_ptr(transformation) as *const () as u64;
710    TEXTFIELD_TRANSFORM_MARKERS.with(|markers| markers.borrow().get(&pointer).copied())
711}
712
713fn apply_textfield_input_transformation_id(id: Option<u64>, state: &mut TextFieldState) {
714    let Some(id) = id else {
715        return;
716    };
717    let input = TEXTFIELD_TRANSFORMS.with(|all| {
718        all.borrow()
719            .get(&id)
720            .and_then(|entry| entry.transforms.input.clone())
721    });
722    let Some(input) = input else {
723        return;
724    };
725    let before = state.text.clone();
726    let composition = state.composition.clone();
727    let mut buffer = TransformationBuffer::new(state.text.clone(), state.selection.clone());
728    input.transform_input(&mut buffer);
729    state.text = buffer.text;
730    state.selection = buffer.selection;
731    if let Some(composition) = composition {
732        state.composition = Some(remap_text_range(&before, &state.text, composition));
733    }
734}
735
736fn remap_text_range(before: &str, after: &str, range: Range<usize>) -> Range<usize> {
737    if before == after {
738        return range;
739    }
740    let mut prefix = before
741        .as_bytes()
742        .iter()
743        .zip(after.as_bytes())
744        .take_while(|(left, right)| left == right)
745        .count();
746    while prefix > 0 && (!before.is_char_boundary(prefix) || !after.is_char_boundary(prefix)) {
747        prefix -= 1;
748    }
749    let max_suffix = (before.len() - prefix).min(after.len() - prefix);
750    let mut suffix = before[before.len() - max_suffix..]
751        .bytes()
752        .rev()
753        .zip(after[after.len() - max_suffix..].bytes().rev())
754        .take_while(|(left, right)| left == right)
755        .count();
756    while suffix > 0
757        && (!before.is_char_boundary(before.len() - suffix)
758            || !after.is_char_boundary(after.len() - suffix))
759    {
760        suffix -= 1;
761    }
762    let new_end = after.len() - suffix;
763    let old_start = prefix.min(range.start.min(before.len()));
764    let old_range_end = range.end.min(before.len()).max(old_start);
765    let map = |offset: usize| {
766        let offset = offset.clamp(old_start, old_range_end);
767        if offset == old_start {
768            prefix
769        } else if offset == old_range_end {
770            new_end
771        } else {
772            let relative = offset - old_start;
773            let changed_len = new_end.saturating_sub(prefix);
774            prefix + relative.min(changed_len)
775        }
776    };
777    let start = map(old_start);
778    let end = map(old_range_end);
779    clamp_range(after, start.min(end)..start.max(end))
780}
781
782pub fn apply_textfield_input_transformation(hit: &HitRegion, state: &mut TextFieldState) {
783    apply_textfield_input_transformation_id(crate::hit_testing::textfield_transform_id(hit), state);
784}
785
786pub fn dispatch_textfield_keyboard_action(
787    hit: &HitRegion,
788    action: repose_core::ImeAction,
789    default: &dyn Fn(),
790) -> bool {
791    crate::hit_testing::dispatch_keyboard_action(hit, action, default)
792}
793
794pub fn textfield_metrics(hit: &HitRegion) -> TextFieldMetrics {
795    crate::hit_testing::textfield_metrics(hit)
796}
797
798pub fn insert_text_with_input_transformation(
799    hit: &HitRegion,
800    state: &mut TextFieldState,
801    text: &str,
802    atomic: bool,
803) -> bool {
804    perform_input_edit(
805        crate::hit_testing::textfield_transform_id(hit),
806        state,
807        |state| {
808            if atomic {
809                state.insert_text_atomic(text);
810            } else {
811                state.insert_text(text);
812            }
813        },
814    )
815    .1
816}
817
818pub fn delete_backward_with_input_transformation(
819    hit: &HitRegion,
820    state: &mut TextFieldState,
821) -> bool {
822    perform_input_edit(
823        crate::hit_testing::textfield_transform_id(hit),
824        state,
825        TextFieldState::delete_backward,
826    )
827    .1
828}
829
830pub fn delete_forward_with_input_transformation(
831    hit: &HitRegion,
832    state: &mut TextFieldState,
833) -> bool {
834    perform_input_edit(
835        crate::hit_testing::textfield_transform_id(hit),
836        state,
837        TextFieldState::delete_forward,
838    )
839    .1
840}
841
842/// Delete surrounding text by byte count, as a soft keyboard reports it. Unlike
843/// repeated [`TextFieldState::delete_backward`] this deletes a selection in one
844/// step, matching the single `deleteContentBackward` the browser fires for one.
845pub fn delete_surrounding_with_input_transformation(
846    hit: &HitRegion,
847    state: &mut TextFieldState,
848    before_bytes: usize,
849    after_bytes: usize,
850) -> bool {
851    perform_input_edit(
852        crate::hit_testing::textfield_transform_id(hit),
853        state,
854        |state| {
855            state.delete_surrounding(before_bytes, after_bytes);
856        },
857    )
858    .1
859}
860
861pub fn cut_with_input_transformation(
862    hit: &HitRegion,
863    state: &mut TextFieldState,
864) -> Option<String> {
865    let selected = state.selected_text();
866    if selected.is_empty() {
867        return None;
868    }
869    let changed = perform_input_edit(
870        crate::hit_testing::textfield_transform_id(hit),
871        state,
872        |state| state.insert_text_atomic(""),
873    )
874    .1;
875    changed.then_some(selected)
876}
877
878pub fn commit_composition_with_input_transformation(
879    hit: &HitRegion,
880    state: &mut TextFieldState,
881    text: String,
882) {
883    perform_input_edit(
884        crate::hit_testing::textfield_transform_id(hit),
885        state,
886        |state| state.commit_composition(text),
887    );
888}
889
890pub fn undo_with_input_transformation(_hit: &HitRegion, state: &mut TextFieldState) -> bool {
891    state.undo()
892}
893
894pub fn redo_with_input_transformation(_hit: &HitRegion, state: &mut TextFieldState) -> bool {
895    state.redo()
896}
897
898pub(crate) fn insert_text_with_input_transformation_config(
899    config: &TextInputConfig,
900    state: &mut TextFieldState,
901    text: &str,
902    atomic: bool,
903) -> bool {
904    let transform_id = config
905        .visual_transformation
906        .as_ref()
907        .and_then(textfield_transform_id);
908    perform_input_edit(transform_id, state, |state| {
909        if atomic {
910            state.insert_text_atomic(text);
911        } else {
912            state.insert_text(text);
913        }
914    })
915    .1
916}
917
918pub(crate) fn cut_with_input_transformation_config(
919    config: &TextInputConfig,
920    state: &mut TextFieldState,
921) -> Option<String> {
922    let selected = state.selected_text();
923    if selected.is_empty() {
924        return None;
925    }
926    let transform_id = config
927        .visual_transformation
928        .as_ref()
929        .and_then(textfield_transform_id);
930    perform_input_edit(transform_id, state, |state| state.insert_text_atomic(""))
931        .1
932        .then_some(selected)
933}
934
935pub(crate) fn undo_with_input_transformation_config(
936    _config: &TextInputConfig,
937    state: &mut TextFieldState,
938) -> bool {
939    state.undo()
940}
941
942pub(crate) fn redo_with_input_transformation_config(
943    _config: &TextInputConfig,
944    state: &mut TextFieldState,
945) -> bool {
946    state.redo()
947}
948
949fn perform_input_edit<T>(
950    transform_id: Option<u64>,
951    state: &mut TextFieldState,
952    edit: impl FnOnce(&mut TextFieldState) -> T,
953) -> (T, bool) {
954    let before = state.text.clone();
955    let before_selection = state.selection.clone();
956    let previous = state.staging_undo.take();
957    let redo = std::mem::take(&mut state.redo_stack);
958    let output = edit(state);
959    let Some(latest) = state.staging_undo.take() else {
960        state.staging_undo = previous;
961        state.redo_stack = redo;
962        return (output, false);
963    };
964    apply_textfield_input_transformation_id(transform_id, state);
965    let canonical = canonical_edit(
966        &before,
967        before_selection,
968        state,
969        latest.time,
970        latest.can_merge,
971    );
972    state.staging_undo = previous;
973    let Some(canonical) = canonical else {
974        state.redo_stack = redo;
975        return (output, false);
976    };
977    state.record_edit(canonical);
978    (output, true)
979}
980
981fn canonical_edit(
982    before: &str,
983    before_selection: Range<usize>,
984    state: &TextFieldState,
985    time: Instant,
986    can_merge: bool,
987) -> Option<TextUndoOp> {
988    canonical_edit_between(
989        before,
990        before_selection,
991        &state.text,
992        state.selection.clone(),
993        time,
994        can_merge,
995    )
996}
997
998fn canonical_edit_between(
999    before: &str,
1000    before_selection: Range<usize>,
1001    after: &str,
1002    after_selection: Range<usize>,
1003    time: Instant,
1004    can_merge: bool,
1005) -> Option<TextUndoOp> {
1006    if before == after {
1007        return None;
1008    }
1009    let mut prefix = before
1010        .as_bytes()
1011        .iter()
1012        .zip(after.as_bytes())
1013        .take_while(|(left, right)| left == right)
1014        .count();
1015    while prefix > 0 && (!before.is_char_boundary(prefix) || !after.is_char_boundary(prefix)) {
1016        prefix -= 1;
1017    }
1018    let max_suffix = (before.len() - prefix).min(after.len() - prefix);
1019    let mut suffix = before[before.len() - max_suffix..]
1020        .bytes()
1021        .rev()
1022        .zip(after[after.len() - max_suffix..].bytes().rev())
1023        .take_while(|(left, right)| left == right)
1024        .count();
1025    while suffix > 0
1026        && (!before.is_char_boundary(before.len() - suffix)
1027            || !after.is_char_boundary(after.len() - suffix))
1028    {
1029        suffix -= 1;
1030    }
1031    let pre_end = before.len() - suffix;
1032    let post_end = after.len() - suffix;
1033    Some(TextUndoOp {
1034        index: prefix,
1035        pre_text: before[prefix..pre_end].to_string(),
1036        post_text: after[prefix..post_end].to_string(),
1037        pre_selection: before_selection,
1038        post_selection: after_selection,
1039        time,
1040        can_merge,
1041    })
1042}
1043
1044/// Measure caret positions for a single-line textfield using shaping.
1045/// `font_px` must match the px size used for rendering the text.
1046/// `font_family` optionally overrides the default font (e.g. for icons).
1047pub fn measure_text(text: &str, font_px: f32, config: TextMeasureConfig) -> TextMetrics {
1048    let m = repose_text::metrics_for_textfield(
1049        text,
1050        font_px,
1051        config.font_family,
1052        config.font_weight,
1053        config.font_style,
1054        config.letter_spacing,
1055        config.font_variation_settings.as_deref(),
1056    );
1057    TextMetrics {
1058        positions: m.positions,
1059        byte_offsets: m.byte_offsets,
1060    }
1061}
1062
1063pub fn byte_to_char_index(m: &TextMetrics, byte: usize) -> usize {
1064    match m.byte_offsets.binary_search(&byte) {
1065        Ok(i) | Err(i) => i,
1066    }
1067}
1068
1069/// Given an x position (px), return the nearest grapheme boundary byte index.
1070pub fn index_for_x_bytes(
1071    text: &str,
1072    font_px: f32,
1073    x_px: f32,
1074    font_weight: u16,
1075    font_style: u8,
1076) -> usize {
1077    let m = measure_text(
1078        text,
1079        font_px,
1080        TextMeasureConfig {
1081            font_weight,
1082            font_style,
1083            ..Default::default()
1084        },
1085    );
1086
1087    if m.positions.is_empty() {
1088        return 0;
1089    }
1090    let mut best_i = 0usize;
1091    let mut best_d = f32::INFINITY;
1092    for (index, position) in m.positions.iter().enumerate() {
1093        let distance = (position - x_px).abs();
1094        if distance < best_d {
1095            best_d = distance;
1096            best_i = index;
1097        }
1098    }
1099    m.byte_offsets[best_i]
1100}
1101
1102/// find prev/next grapheme boundaries around a byte index
1103pub(crate) fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
1104    let mut last = 0usize;
1105    for (i, _) in text.grapheme_indices(true) {
1106        if i >= byte {
1107            break;
1108        }
1109        last = i;
1110    }
1111    last
1112}
1113
1114pub(crate) fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
1115    for (i, _) in text.grapheme_indices(true) {
1116        if i > byte {
1117            return i;
1118        }
1119    }
1120    text.len()
1121}
1122
1123/// Find the word boundaries around the given byte index.
1124/// Selects alphanumeric+underscore runs. Falls back to the grapheme cluster.
1125pub(crate) fn word_range(text: &str, byte: usize) -> (usize, usize) {
1126    let byte = byte.min(text.len());
1127
1128    let mut start = byte;
1129    while start > 0 {
1130        let p = prev_grapheme_boundary(text, start);
1131        if is_word_grapheme(&text[p..start]) {
1132            start = p;
1133        } else {
1134            break;
1135        }
1136    }
1137    let mut end = byte;
1138    while end < text.len() {
1139        let n = next_grapheme_boundary(text, end);
1140        if is_word_grapheme(&text[end..n]) {
1141            end = n;
1142        } else {
1143            break;
1144        }
1145    }
1146    if start == end {
1147        let s = if byte == 0 {
1148            0
1149        } else {
1150            prev_grapheme_boundary(text, byte)
1151        };
1152        let e = next_grapheme_boundary(text, byte);
1153        (s, e.max(s))
1154    } else {
1155        (start, end)
1156    }
1157}
1158
1159fn is_word_char(c: char) -> bool {
1160    c.is_alphanumeric() || c == '_'
1161}
1162
1163fn is_word_grapheme(grapheme: &str) -> bool {
1164    !grapheme.is_empty() && grapheme.chars().all(is_word_char)
1165}
1166
1167fn step_over(pos: &mut usize, text: &str, want_word: bool) {
1168    while *pos < text.len() {
1169        let next = next_grapheme_boundary(text, *pos);
1170        if is_word_grapheme(&text[*pos..next]) != want_word {
1171            return;
1172        }
1173        *pos = next;
1174    }
1175}
1176
1177fn step_back(pos: &mut usize, text: &str, want_word: bool) {
1178    while *pos > 0 {
1179        let prev = prev_grapheme_boundary(text, *pos);
1180        if is_word_grapheme(&text[prev..*pos]) != want_word {
1181            return;
1182        }
1183        *pos = prev;
1184    }
1185}
1186
1187/// Where the caret lands for Ctrl+ArrowLeft: the start of the word it is in, or
1188/// the start of the word before the one it just finished.
1189pub fn prev_word_boundary(text: &str, byte: usize) -> usize {
1190    let origin = byte.min(text.len());
1191    let mut pos = origin;
1192    step_back(&mut pos, text, true);
1193    if pos == origin {
1194        step_back(&mut pos, text, false);
1195        step_back(&mut pos, text, true);
1196    }
1197    pos
1198}
1199
1200/// Where the caret lands for Ctrl+ArrowRight: the end of the word it is in, or
1201/// the end of the word after the one it just finished.
1202pub fn next_word_boundary(text: &str, byte: usize) -> usize {
1203    let origin = byte.min(text.len());
1204    let mut pos = origin;
1205    step_over(&mut pos, text, true);
1206    if pos == origin {
1207        step_over(&mut pos, text, false);
1208        step_over(&mut pos, text, true);
1209    }
1210    pos
1211}
1212
1213pub struct TextFieldState {
1214    pub text: String,
1215    pub selection: Range<usize>,
1216    pub composition: Option<Range<usize>>, // IME composition range (byte offsets)
1217    pub scroll_offset: f32,                // px (x) - current animated display value
1218    pub scroll_offset_y: f32,              // px (y) for multiline - current animated display value
1219    pub drag_anchor: Option<usize>,        // byte index where drag began
1220
1221    // Double/triple-tap tracking
1222    pub(crate) last_tap_time: Option<Instant>,
1223    pub(crate) last_tap_pos: Option<(f32, f32)>,
1224    pub(crate) tap_count: u8,
1225
1226    pub blink_start: Instant,        // caret blink timer
1227    pub inner_width: f32,            // px
1228    pub inner_height: f32,           // px
1229    pub preferred_x_px: Option<f32>, // for Up/Down caret movement in multiline
1230    /// When a visual transformation is active, this maps offsets in the
1231    /// display text back to offsets in the original text.
1232    pub offset_map: Option<Box<dyn OffsetMapping>>,
1233    /// The active visual transformation, set during layout.
1234    pub visual_transformation: Option<Rc<dyn VisualTransformation>>,
1235    /// Target horizontal scroll offset (where we're animating toward).
1236    pub(crate) scroll_target: f32,
1237    /// Target vertical scroll offset.
1238    pub(crate) scroll_target_y: f32,
1239    /// Spring velocity for horizontal scroll animation.
1240    scroll_vel: f32,
1241    /// Spring velocity for vertical scroll animation.
1242    scroll_vel_y: f32,
1243    /// Last time tick_scroll_animation was called (for dt computation).
1244    last_scroll_tick: Option<Instant>,
1245
1246    // Undo/Redo
1247    /// Stack of undo operations (most recent at end).
1248    undo_stack: Vec<TextUndoOp>,
1249    /// Stack of redo operations (most recent at end).
1250    redo_stack: Vec<TextUndoOp>,
1251    /// Staging area for the latest operation that may still merge.
1252    staging_undo: Option<TextUndoOp>,
1253}
1254
1255impl std::fmt::Debug for TextFieldState {
1256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1257        f.debug_struct("TextFieldState")
1258            .field("text", &self.text)
1259            .field("selection", &self.selection)
1260            .field("composition", &self.composition)
1261            .field("scroll_offset", &self.scroll_offset)
1262            .field("scroll_offset_y", &self.scroll_offset_y)
1263            .field("drag_anchor", &self.drag_anchor)
1264            .field("blink_start", &self.blink_start)
1265            .field("inner_width", &self.inner_width)
1266            .field("inner_height", &self.inner_height)
1267            .field("preferred_x_px", &self.preferred_x_px)
1268            .field(
1269                "offset_map",
1270                &self.offset_map.as_ref().map(|_| "<offset_mapping>"),
1271            )
1272            .field(
1273                "visual_transformation",
1274                &self.visual_transformation.as_ref().map(|_| "<vt>"),
1275            )
1276            .field("scroll_target", &self.scroll_target)
1277            .field("scroll_target_y", &self.scroll_target_y)
1278            .field("can_undo", &self.can_undo())
1279            .field("can_redo", &self.can_redo())
1280            .field("undo_count", &self.undo_stack.len())
1281            .field("redo_count", &self.redo_stack.len())
1282            .finish()
1283    }
1284}
1285
1286impl Default for TextFieldState {
1287    fn default() -> Self {
1288        Self::new()
1289    }
1290}
1291
1292impl Clone for TextFieldState {
1293    fn clone(&self) -> Self {
1294        Self {
1295            text: self.text.clone(),
1296            selection: self.selection.clone(),
1297            composition: self.composition.clone(),
1298            scroll_offset: self.scroll_offset,
1299            scroll_offset_y: self.scroll_offset_y,
1300            drag_anchor: self.drag_anchor,
1301            last_tap_time: self.last_tap_time,
1302            last_tap_pos: self.last_tap_pos,
1303            tap_count: self.tap_count,
1304            blink_start: self.blink_start,
1305            inner_width: self.inner_width,
1306            inner_height: self.inner_height,
1307            preferred_x_px: self.preferred_x_px,
1308            offset_map: self.offset_map.as_ref().map(|m| m.clone_box()),
1309            visual_transformation: self.visual_transformation.clone(),
1310            scroll_target: self.scroll_target,
1311            scroll_target_y: self.scroll_target_y,
1312            scroll_vel: self.scroll_vel,
1313            scroll_vel_y: self.scroll_vel_y,
1314            last_scroll_tick: self.last_scroll_tick,
1315            undo_stack: self.undo_stack.clone(),
1316            redo_stack: self.redo_stack.clone(),
1317            staging_undo: self.staging_undo.clone(),
1318        }
1319    }
1320}
1321
1322impl TextFieldState {
1323    pub fn new() -> Self {
1324        Self {
1325            text: String::new(),
1326            selection: 0..0,
1327            composition: None,
1328            scroll_offset: 0.0,
1329            scroll_offset_y: 0.0,
1330            drag_anchor: None,
1331            last_tap_time: None,
1332            last_tap_pos: None,
1333            tap_count: 0,
1334            blink_start: Instant::now(),
1335            inner_width: 0.0,
1336            inner_height: 0.0,
1337            preferred_x_px: None,
1338            offset_map: None,
1339            visual_transformation: None,
1340            scroll_target: 0.0,
1341            scroll_target_y: 0.0,
1342            scroll_vel: 0.0,
1343            scroll_vel_y: 0.0,
1344            last_scroll_tick: None,
1345            undo_stack: Vec::new(),
1346            redo_stack: Vec::new(),
1347            staging_undo: None,
1348        }
1349    }
1350
1351    // Undo/Redo
1352
1353    /// Whether there is an action to undo.
1354    pub fn can_undo(&self) -> bool {
1355        !self.undo_stack.is_empty() || self.staging_undo.is_some()
1356    }
1357
1358    /// Whether there is an action to redo.
1359    pub fn can_redo(&self) -> bool {
1360        !self.redo_stack.is_empty()
1361    }
1362
1363    /// Revert the latest edit. Returns true if an undo was performed.
1364    pub fn undo(&mut self) -> bool {
1365        self.flush_undo();
1366        if let Some(op) = self.undo_stack.pop() {
1367            let idx = clamp_to_char_boundary(&self.text, op.index.min(self.text.len()));
1368            let end = clamp_to_char_boundary(
1369                &self.text,
1370                (op.index + op.post_text.len()).min(self.text.len()),
1371            );
1372            let end = end.max(idx);
1373            self.text.replace_range(idx..end, &op.pre_text);
1374            self.selection = op.pre_selection.clone();
1375            self.redo_stack.push(op);
1376            self.preferred_x_px = None;
1377            self.reset_caret_blink();
1378            true
1379        } else {
1380            false
1381        }
1382    }
1383
1384    /// Re-apply a previously undone edit. Returns true if a redo was performed.
1385    pub fn redo(&mut self) -> bool {
1386        if let Some(op) = self.redo_stack.pop() {
1387            let idx = clamp_to_char_boundary(&self.text, op.index.min(self.text.len()));
1388            let end = clamp_to_char_boundary(
1389                &self.text,
1390                (op.index + op.pre_text.len()).min(self.text.len()),
1391            );
1392            let end = end.max(idx);
1393            self.text.replace_range(idx..end, &op.post_text);
1394            self.selection = op.post_selection.clone();
1395            self.undo_stack.push(op);
1396            self.preferred_x_px = None;
1397            self.reset_caret_blink();
1398            true
1399        } else {
1400            false
1401        }
1402    }
1403
1404    /// Clear all undo/redo history.
1405    pub fn clear_undo_history(&mut self) {
1406        self.undo_stack.clear();
1407        self.redo_stack.clear();
1408        self.staging_undo = None;
1409    }
1410
1411    /// Push a [TextUndoOp] to the staging area, possibly merging with the
1412    /// previous staging operation. Flushes staging to the undo stack when
1413    /// merge is not possible.
1414    fn record_edit(&mut self, op: TextUndoOp) {
1415        self.redo_stack.clear();
1416        if let Some(staging) = self.staging_undo.take() {
1417            if let Some(merged) = staging.try_merge(&op) {
1418                self.staging_undo = Some(merged);
1419                return;
1420            }
1421            // Can't merge: flush staging to undo stack
1422            self.undo_stack.push(staging);
1423            // Enforce capacity: drop oldest entries
1424            while self.undo_stack.len() + 1 > TEXT_UNDO_CAPACITY {
1425                self.undo_stack.remove(0);
1426            }
1427        }
1428        self.staging_undo = Some(op);
1429    }
1430
1431    /// Flush the staging operation into the undo stack.
1432    fn flush_undo(&mut self) {
1433        if let Some(op) = self.staging_undo.take() {
1434            self.undo_stack.push(op);
1435            self.redo_stack.clear();
1436            while self.undo_stack.len() > TEXT_UNDO_CAPACITY {
1437                self.undo_stack.remove(0);
1438            }
1439        }
1440    }
1441
1442    fn insert_text_impl(&mut self, text: &str, can_merge: bool) {
1443        let a = self.selection.start.min(self.text.len());
1444        let b = self.selection.end.min(self.text.len());
1445        let start = clamp_to_char_boundary(&self.text, a.min(b));
1446        let end = clamp_to_char_boundary(&self.text, a.max(b));
1447        let pre_text = self.text[start..end].to_string();
1448        let pre_selection = self.selection.clone();
1449
1450        self.text.replace_range(start..end, text);
1451        let new_pos = start + text.len();
1452        self.selection = new_pos..new_pos;
1453        self.preferred_x_px = None;
1454        self.reset_caret_blink();
1455
1456        if !pre_text.is_empty() || !text.is_empty() {
1457            self.record_edit(TextUndoOp {
1458                index: start,
1459                pre_text,
1460                post_text: text.to_string(),
1461                pre_selection,
1462                post_selection: self.selection.clone(),
1463                time: Instant::now(),
1464                can_merge,
1465            });
1466        }
1467    }
1468
1469    pub fn insert_text(&mut self, text: &str) {
1470        self.insert_text_impl(text, true);
1471    }
1472
1473    /// Like `insert_text` but marks the operation as unmergeable (for cut/paste).
1474    pub fn insert_text_atomic(&mut self, text: &str) {
1475        self.insert_text_impl(text, false);
1476    }
1477
1478    pub fn delete_backward(&mut self) {
1479        if self.selection.start == self.selection.end {
1480            let pos = self.selection.start.min(self.text.len());
1481            if pos > 0 {
1482                let prev = prev_grapheme_boundary(&self.text, pos);
1483                let pre_text = self.text[prev..pos].to_string();
1484                let pre_selection = self.selection.clone();
1485                self.text.replace_range(prev..pos, "");
1486                self.selection = prev..prev;
1487                self.preferred_x_px = None;
1488                self.reset_caret_blink();
1489                self.record_edit(TextUndoOp {
1490                    index: prev,
1491                    pre_text,
1492                    post_text: String::new(),
1493                    pre_selection,
1494                    post_selection: self.selection.clone(),
1495                    time: Instant::now(),
1496                    can_merge: true,
1497                });
1498            }
1499        } else {
1500            self.insert_text_impl("", true);
1501        }
1502        self.preferred_x_px = None;
1503        self.reset_caret_blink();
1504    }
1505
1506    pub fn delete_forward(&mut self) {
1507        if self.selection.start == self.selection.end {
1508            let pos = self.selection.start.min(self.text.len());
1509            if pos < self.text.len() {
1510                let next = next_grapheme_boundary(&self.text, pos);
1511                let pre_text = self.text[pos..next].to_string();
1512                let pre_selection = self.selection.clone();
1513                self.text.replace_range(pos..next, "");
1514                self.preferred_x_px = None;
1515                self.reset_caret_blink();
1516                self.record_edit(TextUndoOp {
1517                    index: pos,
1518                    pre_text,
1519                    post_text: String::new(),
1520                    pre_selection,
1521                    post_selection: self.selection.clone(),
1522                    time: Instant::now(),
1523                    can_merge: true,
1524                });
1525            }
1526        } else {
1527            self.insert_text_impl("", true);
1528        }
1529        self.preferred_x_px = None;
1530        self.reset_caret_blink();
1531    }
1532
1533    /// Move the caret `delta` graphemes, or by word when `by_word` is set, which
1534    /// is what the platform's Ctrl+Arrow does (Cmd+Arrow on macOS). With
1535    /// `extend_selection` the caret moves and the anchor stays, so
1536    /// Ctrl+Shift+Arrow selects a word at a time.
1537    pub fn move_cursor(&mut self, delta: isize, extend_selection: bool, by_word: bool) {
1538        let origin = self.selection.end.min(self.text.len());
1539        let pos = if !by_word {
1540            let mut pos = origin;
1541            if delta < 0 {
1542                for _ in 0..delta.unsigned_abs() {
1543                    pos = prev_grapheme_boundary(&self.text, pos);
1544                }
1545            } else if delta > 0 {
1546                for _ in 0..(delta as usize) {
1547                    pos = next_grapheme_boundary(&self.text, pos);
1548                }
1549            }
1550            pos
1551        } else if delta < 0 {
1552            prev_word_boundary(&self.text, origin)
1553        } else {
1554            next_word_boundary(&self.text, origin)
1555        };
1556        if extend_selection {
1557            self.selection.end = pos;
1558        } else {
1559            self.selection = pos..pos;
1560        }
1561        self.preferred_x_px = None;
1562        self.reset_caret_blink();
1563    }
1564
1565    pub fn selected_text(&self) -> String {
1566        let a = self.selection.start.min(self.text.len());
1567        let b = self.selection.end.min(self.text.len());
1568        let (lo, hi) = (a.min(b), a.max(b));
1569        if lo == hi {
1570            String::new()
1571        } else {
1572            let lo = clamp_to_char_boundary(&self.text, lo);
1573            let hi = clamp_to_char_boundary(&self.text, hi);
1574            self.text[lo..hi].to_string()
1575        }
1576    }
1577
1578    /// Normalized (ordered, clamped) selection range.
1579    pub fn selection_range(&self) -> std::ops::Range<usize> {
1580        let a = self.selection.start.min(self.text.len());
1581        let b = self.selection.end.min(self.text.len());
1582        let (mut lo, mut hi) = (a.min(b), a.max(b));
1583        lo = clamp_to_char_boundary(&self.text, lo);
1584        hi = clamp_to_char_boundary(&self.text, hi);
1585        lo..hi
1586    }
1587
1588    pub fn set_composition(&mut self, text: String, cursor: Option<(usize, usize)>) {
1589        if text.is_empty() {
1590            if let Some(range) = self.composition.take() {
1591                let s = clamp_to_char_boundary(&self.text, range.start.min(self.text.len()));
1592                let e = clamp_to_char_boundary(&self.text, range.end.min(self.text.len()));
1593                if s <= e {
1594                    self.text.replace_range(s..e, "");
1595                    self.selection = s..s;
1596                }
1597            }
1598            self.preferred_x_px = None;
1599            self.reset_caret_blink();
1600            return;
1601        }
1602
1603        let anchor_start;
1604        if let Some(r) = self.composition.take() {
1605            let mut s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
1606            let mut e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
1607            if e < s {
1608                std::mem::swap(&mut s, &mut e);
1609            }
1610            self.text.replace_range(s..e, &text);
1611            anchor_start = s;
1612        } else {
1613            let pos = clamp_to_char_boundary(&self.text, self.selection.start.min(self.text.len()));
1614            self.text.insert_str(pos, &text);
1615            anchor_start = pos;
1616        }
1617
1618        self.composition = Some(anchor_start..(anchor_start + text.len()));
1619
1620        if let Some((c0, c1)) = cursor {
1621            let b0 = clamp_cursor_in_str(&text, c0);
1622            let b1 = clamp_cursor_in_str(&text, c1);
1623            self.selection = (anchor_start + b0)..(anchor_start + b1);
1624        } else {
1625            let end = anchor_start + text.len();
1626            self.selection = end..end;
1627        }
1628
1629        self.preferred_x_px = None;
1630        self.reset_caret_blink();
1631    }
1632
1633    pub fn commit_composition(&mut self, text: String) {
1634        let pre_selection = self.selection.clone();
1635        if let Some(r) = self.composition.take() {
1636            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
1637            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
1638            let pre_text = self.text[s..e].to_string();
1639            self.text.replace_range(s..e, &text);
1640            let new_pos = s + text.len();
1641            self.selection = new_pos..new_pos;
1642            self.preferred_x_px = None;
1643            self.reset_caret_blink();
1644            if !pre_text.is_empty() || !text.is_empty() {
1645                self.record_edit(TextUndoOp {
1646                    index: s,
1647                    pre_text,
1648                    post_text: text,
1649                    pre_selection,
1650                    post_selection: self.selection.clone(),
1651                    time: Instant::now(),
1652                    can_merge: true,
1653                });
1654            }
1655        } else {
1656            let pos = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
1657            self.text.insert_str(pos, &text);
1658            let new_pos = pos + text.len();
1659            self.selection = new_pos..new_pos;
1660            self.preferred_x_px = None;
1661            self.reset_caret_blink();
1662            if !text.is_empty() {
1663                self.record_edit(TextUndoOp {
1664                    index: pos,
1665                    pre_text: String::new(),
1666                    post_text: text,
1667                    pre_selection,
1668                    post_selection: self.selection.clone(),
1669                    time: Instant::now(),
1670                    can_merge: true,
1671                });
1672            }
1673        }
1674    }
1675
1676    pub fn finish_composition(&mut self) {
1677        self.composition = None;
1678        self.preferred_x_px = None;
1679        self.reset_caret_blink();
1680    }
1681
1682    pub fn cancel_composition(&mut self) {
1683        if let Some(r) = self.composition.take() {
1684            let s = clamp_to_char_boundary(&self.text, r.start.min(self.text.len()));
1685            let e = clamp_to_char_boundary(&self.text, r.end.min(self.text.len()));
1686            if s <= e {
1687                self.text.replace_range(s..e, "");
1688                self.selection = s..s;
1689            }
1690        }
1691        self.preferred_x_px = None;
1692        self.reset_caret_blink();
1693    }
1694}
1695
1696fn clamp_cursor_in_str(s: &str, i: usize) -> usize {
1697    let mut j = i.min(s.len());
1698    while j > 0 && !s.is_char_boundary(j) {
1699        j -= 1;
1700    }
1701    j
1702}
1703
1704fn is_grapheme_boundary(text: &str, byte: usize) -> bool {
1705    if byte == 0 || byte == text.len() {
1706        return true;
1707    }
1708    if !text.is_char_boundary(byte) {
1709        return false;
1710    }
1711    for (i, _) in text.grapheme_indices(true) {
1712        if i == byte {
1713            return true;
1714        }
1715        if i > byte {
1716            return false;
1717        }
1718    }
1719    false
1720}
1721
1722/// Smallest grapheme boundary at or after `pos` (inward for a delete start).
1723fn snap_inward_start(text: &str, pos: usize) -> usize {
1724    let pos = pos.min(text.len());
1725    if is_grapheme_boundary(text, pos) {
1726        return pos;
1727    }
1728    next_grapheme_boundary(text, pos)
1729}
1730
1731/// Largest grapheme boundary at or before `pos` (inward for a delete end).
1732fn snap_inward_end(text: &str, pos: usize) -> usize {
1733    let pos = pos.min(text.len());
1734    if is_grapheme_boundary(text, pos) {
1735        return pos;
1736    }
1737    let mut b = pos;
1738    while b > 0 && !text.is_char_boundary(b) {
1739        b -= 1;
1740    }
1741    if is_grapheme_boundary(text, b) {
1742        return b;
1743    }
1744    prev_grapheme_boundary(text, pos)
1745}
1746
1747impl TextFieldState {
1748    /// Delete `before_bytes` before the caret and `after_bytes` after it, as a
1749    /// soft keyboard reports a surrounding-text deletion. A non-empty selection
1750    /// is deleted instead, as one edit however long it is.
1751    pub fn delete_surrounding(&mut self, before_bytes: usize, after_bytes: usize) {
1752        let range = if self.selection.start != self.selection.end {
1753            self.selection_range()
1754        } else {
1755            let caret = clamp_to_char_boundary(&self.text, self.selection.end.min(self.text.len()));
1756            let start =
1757                snap_inward_start(&self.text, caret.saturating_sub(before_bytes)).min(caret);
1758            let end =
1759                snap_inward_end(&self.text, (caret + after_bytes).min(self.text.len())).max(caret);
1760            if start >= end {
1761                self.preferred_x_px = None;
1762                self.reset_caret_blink();
1763                return;
1764            }
1765            start..end
1766        };
1767
1768        let pre_text = self.text[range.clone()].to_string();
1769        let pre_selection = self.selection.clone();
1770        self.text.replace_range(range.clone(), "");
1771        self.selection = range.start..range.start;
1772        self.preferred_x_px = None;
1773        self.reset_caret_blink();
1774        self.record_edit(TextUndoOp {
1775            index: range.start,
1776            pre_text,
1777            post_text: String::new(),
1778            pre_selection,
1779            post_selection: self.selection.clone(),
1780            time: Instant::now(),
1781            can_merge: true,
1782        });
1783    }
1784
1785    pub fn begin_drag(&mut self, idx_byte: usize, extend: bool) {
1786        let idx = idx_byte.min(self.text.len());
1787        if extend {
1788            let anchor = self.selection.start;
1789            self.selection = anchor.min(idx)..anchor.max(idx);
1790            self.drag_anchor = Some(anchor);
1791        } else {
1792            self.selection = idx..idx;
1793            self.drag_anchor = Some(idx);
1794        }
1795        self.preferred_x_px = None;
1796        self.reset_caret_blink();
1797    }
1798
1799    pub fn drag_to(&mut self, idx_byte: usize) {
1800        if let Some(anchor) = self.drag_anchor {
1801            let i = idx_byte.min(self.text.len());
1802            self.selection = anchor.min(i)..anchor.max(i);
1803        }
1804        self.preferred_x_px = None;
1805        self.reset_caret_blink();
1806        if self.selection.start < self.selection.end {
1807            repose_core::clipboard::set_primary_selection(
1808                &self.text[self.selection.start..self.selection.end],
1809            );
1810        }
1811    }
1812    pub fn end_drag(&mut self) {
1813        self.drag_anchor = None;
1814        if self.selection.start < self.selection.end {
1815            repose_core::clipboard::set_primary_selection(
1816                &self.text[self.selection.start..self.selection.end],
1817            );
1818        }
1819    }
1820
1821    pub fn handle_pointer_down(&mut self, idx_byte: usize, pos_px: (f32, f32), shift: bool) {
1822        const DOUBLE_TAP_MS: u64 = 300;
1823        const TAP_SLOP_PX: f32 = 12.0;
1824
1825        let now = Instant::now();
1826        let mut count = self.tap_count;
1827        if let (Some(t), Some(p)) = (self.last_tap_time, self.last_tap_pos) {
1828            let dt = now.saturating_duration_since(t);
1829            let dist = ((pos_px.0 - p.0).powi(2) + (pos_px.1 - p.1).powi(2)).sqrt();
1830            if dt < Duration::from_millis(DOUBLE_TAP_MS) && dist < TAP_SLOP_PX {
1831                count = count.saturating_add(1);
1832            } else {
1833                count = 1;
1834            }
1835        } else {
1836            count = 1;
1837        }
1838        self.tap_count = count;
1839        self.last_tap_time = Some(now);
1840        self.last_tap_pos = Some(pos_px);
1841
1842        let idx = idx_byte.min(self.text.len());
1843
1844        if count > 3 {
1845            count = (count - 1) % 3 + 1;
1846            self.tap_count = count;
1847        }
1848        if count >= 3 {
1849            self.selection = 0..self.text.len();
1850            self.drag_anchor = None;
1851            self.preferred_x_px = None;
1852            self.reset_caret_blink();
1853            if self.selection.end > 0 {
1854                repose_core::clipboard::set_primary_selection(&self.text);
1855            }
1856            return;
1857        }
1858
1859        if count == 2 {
1860            // Double-tap: select word
1861            let (s, e) = word_range(&self.text, idx);
1862            self.selection = s..e;
1863            self.drag_anchor = Some(s);
1864            self.preferred_x_px = None;
1865            self.reset_caret_blink();
1866            if e > s {
1867                repose_core::clipboard::set_primary_selection(&self.text[s..e]);
1868            }
1869            return;
1870        }
1871
1872        // Single tap
1873        self.begin_drag(idx, shift);
1874    }
1875
1876    /// Select the word at the given byte index.
1877    pub fn select_word_at(&mut self, byte: usize) {
1878        let (s, e) = word_range(&self.text, byte.min(self.text.len()));
1879        self.selection = s..e;
1880        self.drag_anchor = Some(s);
1881        self.preferred_x_px = None;
1882        self.reset_caret_blink();
1883    }
1884
1885    /// Select all text.
1886    pub fn select_all(&mut self) {
1887        self.selection = 0..self.text.len();
1888        self.drag_anchor = None;
1889        self.preferred_x_px = None;
1890        self.reset_caret_blink();
1891    }
1892
1893    pub fn caret_index(&self) -> usize {
1894        self.selection.end
1895    }
1896
1897    /// Keep caret visible inside inner content width (px).
1898    /// `inset_px` is a small padding (px) to avoid hugging edges.
1899    /// Sets the scroll target for smooth animated scrolling.
1900    pub fn ensure_caret_visible(&mut self, caret_x_px: f32, inner_width_px: f32, inset_px: f32) {
1901        self.ensure_caret_visible_xy(caret_x_px, 0.0, inner_width_px, 1.0, inset_px);
1902    }
1903
1904    /// Keep caret visible inside an inner rect (for multiline).
1905    /// Sets the scroll target for smooth animated scrolling.
1906    pub fn ensure_caret_visible_xy(
1907        &mut self,
1908        caret_x_px: f32,
1909        caret_y_px: f32,
1910        inner_w_px: f32,
1911        inner_h_px: f32,
1912        inset_px: f32,
1913    ) {
1914        let inset_px = inset_px.max(0.0);
1915
1916        // Compute target X scroll based on current display offset
1917        let left_px = self.scroll_offset + inset_px;
1918        let right_px = self.scroll_offset + inner_w_px - inset_px;
1919        if caret_x_px < left_px {
1920            self.scroll_target = (caret_x_px - inset_px).max(0.0);
1921        } else if caret_x_px > right_px {
1922            self.scroll_target = (caret_x_px - inner_w_px + inset_px).max(0.0);
1923        }
1924
1925        // Compute target Y scroll based on current display offset
1926        let top_px = self.scroll_offset_y + inset_px;
1927        let bot_px = self.scroll_offset_y + inner_h_px - inset_px;
1928        if caret_y_px < top_px {
1929            self.scroll_target_y = (caret_y_px - inset_px).max(0.0);
1930        } else if caret_y_px > bot_px {
1931            self.scroll_target_y = (caret_y_px - inner_h_px + inset_px).max(0.0);
1932        }
1933    }
1934
1935    pub fn clamp_scroll(&mut self, content_h_px: f32) {
1936        let max_y = (content_h_px - self.inner_height).max(0.0);
1937        self.scroll_target_y = self.scroll_target_y.clamp(0.0, max_y);
1938        if self.scroll_target_y.is_nan() {
1939            self.scroll_target_y = 0.0;
1940        }
1941    }
1942
1943    pub fn reset_caret_blink(&mut self) {
1944        self.blink_start = Instant::now();
1945    }
1946    pub fn caret_visible(&self) -> bool {
1947        const PERIOD: Duration = Duration::from_millis(500);
1948        ((Instant::now() - self.blink_start).as_millis() / PERIOD.as_millis()).is_multiple_of(2)
1949    }
1950
1951    /// If the selection is collapsed (caret is visible), return the [`Instant`]
1952    /// of the next 500 ms blink boundary.
1953    pub fn next_blink_deadline(&self) -> Option<Instant> {
1954        if self.selection.start != self.selection.end {
1955            return None;
1956        }
1957        const PERIOD_MS: u128 = 500;
1958        let now = Instant::now();
1959        let elapsed = now.saturating_duration_since(self.blink_start).as_millis();
1960        let next_tick = (elapsed / PERIOD_MS) + 1;
1961        Some(self.blink_start + Duration::from_millis((next_tick * PERIOD_MS) as u64))
1962    }
1963
1964    /// Seed state from a controlled value (first paint / first focus).
1965    pub fn with_text(text: impl Into<String>) -> Self {
1966        let mut s = Self::new();
1967        s.text = text.into();
1968        s.selection = 0..0;
1969        s
1970    }
1971
1972    /// Place caret at end of current text (keyboard focus / explicit API).
1973    pub fn place_cursor_at_end(&mut self) {
1974        let len = self.text.len();
1975        self.selection = len..len;
1976        self.drag_anchor = None;
1977        self.preferred_x_px = None;
1978        self.reset_caret_blink();
1979    }
1980
1981    /// Apply host-controlled value without clobbering IME composition or a
1982    /// user-placed caret more than necessary.
1983    /// Call *before* paint and *before* pointer→index mapping.
1984    pub fn apply_controlled_value(&mut self, value: &str) {
1985        if self.text.as_str() == value {
1986            return;
1987        }
1988        if self.composition.is_some() {
1989            return;
1990        }
1991        self.text = value.to_string();
1992        let len = self.text.len();
1993        let ns = clamp_to_char_boundary(&self.text, self.selection.start.min(len));
1994        let ne = clamp_to_char_boundary(&self.text, self.selection.end.min(len));
1995        self.selection = ns..ne;
1996        self.drag_anchor = None;
1997    }
1998
1999    pub fn set_inner_width(&mut self, w_px: f32) {
2000        self.inner_width = w_px.max(0.0);
2001        if self.scroll_offset.is_nan() {
2002            self.scroll_offset = 0.0;
2003        }
2004        if self.scroll_target.is_nan() {
2005            self.scroll_target = 0.0;
2006        }
2007    }
2008    pub fn set_inner_height(&mut self, h_px: f32) {
2009        self.inner_height = h_px.max(0.0);
2010        if self.scroll_offset_y.is_nan() {
2011            self.scroll_offset_y = 0.0;
2012        }
2013        if self.scroll_target_y.is_nan() {
2014            self.scroll_target_y = 0.0;
2015        }
2016    }
2017
2018    /// Advance scroll animation by actual wall-clock dt using spring physics.
2019    /// Call this once per frame before reading [scroll_offset] / [scroll_offset_y].
2020    /// On the first call after a target change, snaps immediately to avoid 1-frame delay.
2021    pub fn tick_scroll_animation(&mut self) {
2022        let now = Instant::now();
2023        let dt = match self.last_scroll_tick {
2024            Some(prev) => {
2025                let d = now.saturating_duration_since(prev).as_secs_f32();
2026                d.min(0.05) // cap to 50ms to avoid jumps after pause
2027            }
2028            None => {
2029                // First tick: snap to target immediately, but record the time
2030                // so subsequent ticks produce a smooth spring.
2031                self.last_scroll_tick = Some(now);
2032                self.scroll_offset = self.scroll_target;
2033                self.scroll_vel = 0.0;
2034                self.scroll_offset_y = self.scroll_target_y;
2035                self.scroll_vel_y = 0.0;
2036                return;
2037            }
2038        };
2039        self.last_scroll_tick = Some(now);
2040
2041        // X axis
2042        if dt > 0.0 {
2043            let dx = self.scroll_target - self.scroll_offset;
2044            let near_x = dx.abs() < 0.5 && self.scroll_vel.abs() < 0.5;
2045            if near_x {
2046                self.scroll_offset = self.scroll_target;
2047                self.scroll_vel = 0.0;
2048            } else {
2049                let force_x = SCROLL_STIFFNESS * dx - SCROLL_DAMPING * self.scroll_vel;
2050                self.scroll_vel += force_x * dt;
2051                self.scroll_offset += self.scroll_vel * dt;
2052                // Overshoot protection: clamp to target if we'd pass it this frame
2053                if (self.scroll_target - self.scroll_offset).signum() != dx.signum() && dx != 0.0 {
2054                    self.scroll_offset = self.scroll_target;
2055                    self.scroll_vel = 0.0;
2056                }
2057            }
2058        }
2059
2060        // Y axis
2061        if dt > 0.0 {
2062            let dy = self.scroll_target_y - self.scroll_offset_y;
2063            let near_y = dy.abs() < 0.5 && self.scroll_vel_y.abs() < 0.5;
2064            if near_y {
2065                self.scroll_offset_y = self.scroll_target_y;
2066                self.scroll_vel_y = 0.0;
2067            } else {
2068                let force_y = SCROLL_STIFFNESS * dy - SCROLL_DAMPING * self.scroll_vel_y;
2069                self.scroll_vel_y += force_y * dt;
2070                self.scroll_offset_y += self.scroll_vel_y * dt;
2071                if (self.scroll_target_y - self.scroll_offset_y).signum() != dy.signum()
2072                    && dy != 0.0
2073                {
2074                    self.scroll_offset_y = self.scroll_target_y;
2075                    self.scroll_vel_y = 0.0;
2076                }
2077            }
2078        }
2079    }
2080}
2081
2082/// Configuration for `BasicTextField` / `BasicSecureTextField`.
2083///
2084/// Use `..Default::default()` for unset fields:
2085/// ```ignore
2086/// BasicTextField(state, modifier, "Hint", TextFieldConfig {
2087///     enabled: false,
2088///     ..Default::default()
2089/// })
2090/// ```
2091#[derive(Clone)]
2092pub struct TextFieldConfig {
2093    /// When false, the text field is not editable, not focusable, and input is not selectable (-> `enabled`).
2094    pub enabled: bool,
2095    /// When true, the text field can be focused and text can be selected/copied, but not modified (-> `readOnly`).
2096    pub read_only: bool,
2097    /// Input transformation (-> `inputTransformation`). Transforms text before it is applied.
2098    pub input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
2099    /// Style for the text content (-> `textStyle`).
2100    pub text_style: repose_core::TextStyle,
2101    /// Platform keyboard configuration hints (-> `keyboardOptions`).
2102    pub keyboard_options: repose_core::KeyboardOptions,
2103    /// Per-action IME callback (-> `onKeyboardAction`).
2104    pub on_keyboard_action: Option<Rc<dyn repose_core::KeyboardActionHandler>>,
2105    /// Line limits (-> `TextFieldLineLimits`).
2106    pub line_limits: repose_core::TextFieldLineLimits,
2107    /// Callback invoked after each text layout computation (-> `onTextLayout`).
2108    pub on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
2109    /// Interaction source for tracking focus/press/hover state.
2110    pub interaction_source: Option<repose_core::MutableInteractionSource>,
2111    /// Tracks focus state during layout. The cell is set to `true` while this
2112    /// field is the focused text input, `false` otherwise.
2113    pub focus_tracker: Option<Rc<Cell<bool>>>,
2114    /// Cursor brush (-> `cursorBrush`). `None` -> theme default (`on_surface`).
2115    pub cursor_brush: Option<repose_core::Brush>,
2116    /// Output transformation (-> `outputTransformation`). Transforms text for display only.
2117    pub output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
2118    /// Decorator (-> `decorator`). Wraps the inner text field with custom decorations.
2119    pub decorator: Option<Rc<dyn repose_core::TextFieldDecorator>>,
2120    /// Internal codepoint transformation for password obfuscation (-> `codepointTransformation`).
2121    pub codepoint_transformation: Option<repose_core::CodepointTransformation>,
2122    /// Text obfuscation mode (-> `textObfuscationMode`). Used by `BasicSecureTextField`.
2123    pub text_obfuscation_mode: repose_core::TextObfuscationMode,
2124    /// Character used for text obfuscation (-> `textObfuscationCharacter`). Used by `BasicSecureTextField`.
2125    pub text_obfuscation_character: char,
2126
2127    // Legacy / reposé-specific (for migration convenience, kept in config)
2128    pub on_change: Option<Rc<dyn Fn(String)>>,
2129    pub on_submit: Option<Rc<dyn Fn(String)>>,
2130    pub visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
2131    pub decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
2132}
2133
2134impl Default for TextFieldConfig {
2135    fn default() -> Self {
2136        Self {
2137            enabled: true,
2138            read_only: false,
2139            input_transformation: None,
2140            text_style: Default::default(),
2141            keyboard_options: repose_core::KeyboardOptions::DEFAULT,
2142            on_keyboard_action: None,
2143            line_limits: repose_core::TextFieldLineLimits::MultiLine {
2144                min_height_in_lines: 1,
2145                max_height_in_lines: usize::MAX,
2146            },
2147            on_text_layout: None,
2148            interaction_source: None,
2149            focus_tracker: None,
2150            cursor_brush: None,
2151            output_transformation: None,
2152            decorator: None,
2153            codepoint_transformation: None,
2154            text_obfuscation_mode: repose_core::TextObfuscationMode::System,
2155            text_obfuscation_character: '\u{2022}',
2156            on_change: None,
2157            on_submit: None,
2158            visual_transformation: None,
2159            decoration_box: None,
2160        }
2161    }
2162}
2163
2164/// State-based text field. Corresponds to Compose's `BasicTextField(state: TextFieldState, ...)`.
2165///
2166/// The state is managed externally and all editing is reflected in the `TextFieldState`
2167/// object passed to the platform runner via `set_textfield_state`.
2168///
2169/// # Example
2170/// ```ignore
2171/// let state = Rc::new(RefCell::new(TextFieldState::new("")));
2172/// BasicTextField(state.clone(), Modifier::new(), "Hint", TextFieldConfig {
2173///     enabled: false,
2174///     ..Default::default()
2175/// })
2176/// ```
2177pub fn BasicTextField(
2178    state: Rc<RefCell<TextFieldState>>,
2179    modifier: repose_core::Modifier,
2180    hint: impl Into<String>,
2181    config: TextFieldConfig,
2182) -> repose_core::View {
2183    let (single_line, max_lines, min_lines) = match config.line_limits {
2184        repose_core::TextFieldLineLimits::SingleLine => (true, 1, 1),
2185        repose_core::TextFieldLineLimits::MultiLine {
2186            min_height_in_lines,
2187            max_height_in_lines,
2188        } => (false, max_height_in_lines, min_height_in_lines),
2189    };
2190
2191    let ka = if let Some(ref handler) = config.on_keyboard_action {
2192        let default_submit = config.on_submit.clone();
2193        let default_state = state.clone();
2194        let make_action = move |handler: Rc<dyn repose_core::KeyboardActionHandler>| {
2195            let default_submit = default_submit.clone();
2196            let default_state = default_state.clone();
2197            Rc::new(move |_: &dyn repose_core::KeyboardActionScope| {
2198                handler.on_keyboard_action(&|| {
2199                    if let Some(callback) = &default_submit {
2200                        let text = default_state.borrow().text.clone();
2201                        callback(text);
2202                    }
2203                });
2204            }) as Rc<dyn Fn(&dyn repose_core::KeyboardActionScope)>
2205        };
2206        repose_core::KeyboardActions {
2207            on_done: Some(make_action(handler.clone())),
2208            on_go: Some(make_action(handler.clone())),
2209            on_next: Some(make_action(handler.clone())),
2210            on_previous: Some(make_action(handler.clone())),
2211            on_search: Some(make_action(handler.clone())),
2212            on_send: Some(make_action(handler.clone())),
2213        }
2214    } else {
2215        repose_core::KeyboardActions::default()
2216    };
2217
2218    let transform_cache_key = format!(
2219        "textfield-transforms:{:x}:{:?}:{:x}:{:x}:{:x}:{}",
2220        Rc::as_ptr(&state) as usize,
2221        modifier.key,
2222        config
2223            .visual_transformation
2224            .as_ref()
2225            .map(|value| Rc::as_ptr(value) as *const () as usize)
2226            .unwrap_or(0),
2227        config
2228            .input_transformation
2229            .as_ref()
2230            .map(|value| Rc::as_ptr(value) as *const () as usize)
2231            .unwrap_or(0),
2232        config
2233            .output_transformation
2234            .as_ref()
2235            .map(|value| Rc::as_ptr(value) as *const () as usize)
2236            .unwrap_or(0),
2237        config.codepoint_transformation.is_some(),
2238    );
2239    let visual_transformation = remember_with_key(transform_cache_key, || {
2240        install_textfield_transforms(
2241            config.visual_transformation,
2242            config.input_transformation,
2243            config.output_transformation,
2244            config.codepoint_transformation,
2245        )
2246    });
2247    let visual_transformation = visual_transformation.as_ref().clone();
2248
2249    let decoration_box = config
2250        .decorator
2251        .map(|d| Rc::new(move |inner: repose_core::View| d.decorate(inner)) as Rc<dyn Fn(_) -> _>);
2252
2253    let cursor_color = config.cursor_brush.and_then(|b| match b {
2254        repose_core::Brush::Solid(c) => Some(c),
2255        _ => None,
2256    });
2257
2258    let value = state.borrow().text.clone();
2259    let key = state.as_ptr() as u64;
2260    set_textfield_state(key, state.clone());
2261
2262    let state_on_change = {
2263        let s = state.clone();
2264        move |new_value: String| {
2265            s.borrow_mut().text = new_value;
2266        }
2267    };
2268
2269    let merged_on_change: Option<Rc<dyn Fn(String)>> =
2270        if let Some(ref cfg_on_change) = config.on_change {
2271            let a = Rc::new(state_on_change) as Rc<dyn Fn(String)>;
2272            let b = cfg_on_change.clone();
2273            Some(Rc::new(move |v: String| {
2274                a(v.clone());
2275                b(v);
2276            }) as Rc<dyn Fn(String)>)
2277        } else {
2278            Some(Rc::new(state_on_change) as Rc<dyn Fn(String)>)
2279        };
2280
2281    text_field_view(
2282        modifier,
2283        hint.into(),
2284        value,
2285        !single_line,
2286        merged_on_change,
2287        config.on_submit,
2288        visual_transformation,
2289        config.keyboard_options.keyboard_type,
2290        config.keyboard_options.capitalization,
2291        config.keyboard_options.ime_action,
2292        config.keyboard_options.auto_correct_enabled,
2293        config.enabled,
2294        config.read_only,
2295        Some(max_lines),
2296        min_lines,
2297        cursor_color,
2298        config.on_text_layout,
2299        config.text_style,
2300        ka,
2301        config.interaction_source,
2302        config.focus_tracker,
2303        Some(config.line_limits),
2304        decoration_box,
2305    )
2306}
2307
2308/// Secure text field for password entry. Corresponds to Compose's `BasicSecureTextField`.
2309///
2310/// Wraps `BasicTextField` with secure defaults: single-line, password keyboard,
2311/// text obfuscation, and disabled cut/copy.
2312///
2313/// Like `BasicTextField` this draws no chrome of its own, so without a `hint` or
2314/// a `decorator` an empty field is invisible. Pass a hint, a decorator, or both.
2315pub fn BasicSecureTextField(
2316    state: Rc<RefCell<TextFieldState>>,
2317    modifier: repose_core::Modifier,
2318    hint: impl Into<String>,
2319    config: TextFieldConfig,
2320) -> repose_core::View {
2321    let mask = config.text_obfuscation_character;
2322    let secure_config = TextFieldConfig {
2323        line_limits: repose_core::TextFieldLineLimits::SingleLine,
2324        keyboard_options: repose_core::KeyboardOptions::SECURE_TEXT_FIELD,
2325        visual_transformation: match config.text_obfuscation_mode {
2326            repose_core::TextObfuscationMode::Visible => None,
2327            _ => Some(Rc::new(repose_core::PasswordVisualTransformation { mask })
2328                as Rc<dyn repose_core::VisualTransformation>),
2329        },
2330        ..config
2331    };
2332    BasicTextField(state, modifier, hint, secure_config)
2333}
2334
2335#[derive(Clone, Debug)]
2336pub struct TextAreaLayout {
2337    pub ranges: Vec<(usize, usize)>,
2338    pub line_h_px: f32,
2339}
2340
2341pub fn layout_text_area(
2342    text: &str,
2343    font_px: f32,
2344    wrap_w_px: f32,
2345    font_weight: u16,
2346    font_style: u8,
2347    letter_spacing: f32,
2348    font_variation_settings: Option<&str>,
2349) -> TextAreaLayout {
2350    layout_text_area_with_metrics(
2351        text,
2352        wrap_w_px,
2353        &TextFieldMetrics {
2354            font_px,
2355            font_weight,
2356            font_style,
2357            letter_spacing_px: letter_spacing,
2358            font_variation_settings: font_variation_settings.map(str::to_string),
2359            line_height_px: font_px,
2360            ..TextFieldMetrics::default()
2361        },
2362    )
2363}
2364
2365pub fn layout_text_area_with_metrics(
2366    text: &str,
2367    wrap_w_px: f32,
2368    metrics: &TextFieldMetrics,
2369) -> TextAreaLayout {
2370    let (ranges, _) = repose_text::wrap_line_ranges(
2371        text,
2372        metrics.font_px,
2373        wrap_w_px.max(1.0),
2374        None,
2375        true,
2376        metrics.font_weight,
2377        metrics.font_style,
2378        metrics.letter_spacing_px,
2379        metrics.font_variation_settings.as_deref(),
2380    );
2381    TextAreaLayout {
2382        ranges,
2383        line_h_px: metrics.line_height_px,
2384    }
2385}
2386
2387fn locate_byte_in_ranges(ranges: &[(usize, usize)], b: usize) -> (usize, usize, usize) {
2388    if ranges.is_empty() {
2389        return (0, 0, b);
2390    }
2391    let next = ranges.partition_point(|(start, _)| *start <= b);
2392    if next == 0 {
2393        return (0, 0, b);
2394    }
2395    let index = next - 1;
2396    let (start, end) = ranges[index];
2397    let local = b.saturating_sub(start).min(end.saturating_sub(start));
2398    (index, local, start + local)
2399}
2400
2401pub fn caret_xy_for_byte(
2402    text: &str,
2403    font_px: f32,
2404    wrap_w_px: f32,
2405    byte: usize,
2406) -> (f32, f32, usize) {
2407    caret_xy_for_byte_with_metrics(
2408        text,
2409        wrap_w_px,
2410        byte,
2411        &TextFieldMetrics {
2412            font_px,
2413            line_height_px: font_px,
2414            ..TextFieldMetrics::default()
2415        },
2416    )
2417}
2418
2419pub fn caret_xy_for_byte_with_metrics(
2420    text: &str,
2421    wrap_w_px: f32,
2422    byte: usize,
2423    metrics: &TextFieldMetrics,
2424) -> (f32, f32, usize) {
2425    let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
2426    let (ranges, line_h) = (&layout.ranges, layout.line_h_px);
2427    let (li, local, _) = locate_byte_in_ranges(ranges, byte);
2428    let (s, e) = ranges.get(li).copied().unwrap_or((0, 0));
2429    let line = &text[s..e];
2430    let measured = measure_text(line, metrics.font_px, metrics.measure_config());
2431    let ci = byte_to_char_index(&measured, local);
2432    let x = measured.positions.get(ci).copied().unwrap_or(0.0);
2433    (x, li as f32 * line_h, li)
2434}
2435
2436pub fn index_for_x_bytes_with_config(
2437    text: &str,
2438    font_px: f32,
2439    x_px: f32,
2440    config: TextMeasureConfig,
2441) -> usize {
2442    let measured = measure_text(text, font_px, config);
2443    if measured.positions.is_empty() {
2444        return 0;
2445    }
2446    let mut best_i = 0usize;
2447    let mut best_d = f32::INFINITY;
2448    for (index, position) in measured.positions.iter().enumerate() {
2449        let distance = (position - x_px).abs();
2450        if distance < best_d {
2451            best_d = distance;
2452            best_i = index;
2453        }
2454    }
2455    measured.byte_offsets[best_i]
2456}
2457
2458pub fn index_for_xy_bytes(text: &str, font_px: f32, wrap_w_px: f32, x_px: f32, y_px: f32) -> usize {
2459    index_for_xy_bytes_with_metrics(
2460        text,
2461        wrap_w_px,
2462        x_px,
2463        y_px,
2464        &TextFieldMetrics {
2465            font_px,
2466            line_height_px: font_px,
2467            ..TextFieldMetrics::default()
2468        },
2469    )
2470}
2471
2472pub fn index_for_xy_bytes_with_metrics(
2473    text: &str,
2474    wrap_w_px: f32,
2475    x_px: f32,
2476    y_px: f32,
2477    metrics: &TextFieldMetrics,
2478) -> usize {
2479    let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
2480    let line = ((y_px / layout.line_h_px).floor() as isize).max(0) as usize;
2481    let line = line.min(layout.ranges.len().saturating_sub(1));
2482    let (start, end) = layout.ranges.get(line).copied().unwrap_or((0, 0));
2483    let local = index_for_x_bytes_with_config(
2484        &text[start..end],
2485        metrics.font_px,
2486        x_px.max(0.0),
2487        metrics.measure_config(),
2488    );
2489    (start + local).min(text.len())
2490}
2491
2492pub fn move_caret_vertical(
2493    text: &str,
2494    font_px: f32,
2495    wrap_w_px: f32,
2496    cur_byte: usize,
2497    dir: i32,
2498    preferred_x: Option<f32>,
2499) -> (usize, f32) {
2500    move_caret_vertical_with_metrics(
2501        text,
2502        wrap_w_px,
2503        cur_byte,
2504        dir,
2505        preferred_x,
2506        &TextFieldMetrics {
2507            font_px,
2508            line_height_px: font_px,
2509            ..TextFieldMetrics::default()
2510        },
2511    )
2512}
2513
2514pub fn move_caret_vertical_with_metrics(
2515    text: &str,
2516    wrap_w_px: f32,
2517    cur_byte: usize,
2518    dir: i32,
2519    preferred_x: Option<f32>,
2520    metrics: &TextFieldMetrics,
2521) -> (usize, f32) {
2522    let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
2523    if layout.ranges.is_empty() {
2524        return (cur_byte, preferred_x.unwrap_or(0.0));
2525    }
2526    let (x, _, line) = caret_xy_for_byte_with_metrics(text, wrap_w_px, cur_byte, metrics);
2527    let x = preferred_x.unwrap_or(x);
2528    let line = (line as i32 + dir).clamp(0, layout.ranges.len().saturating_sub(1) as i32) as usize;
2529    let (start, end) = layout.ranges[line];
2530    let local = index_for_x_bytes_with_config(
2531        &text[start..end],
2532        metrics.font_px,
2533        x.max(0.0),
2534        metrics.measure_config(),
2535    );
2536    ((start + local).min(text.len()), x)
2537}
2538
2539pub fn line_home_end(
2540    text: &str,
2541    font_px: f32,
2542    wrap_w_px: f32,
2543    cur_byte: usize,
2544    to_end: bool,
2545) -> usize {
2546    line_home_end_with_metrics(
2547        text,
2548        wrap_w_px,
2549        cur_byte,
2550        to_end,
2551        &TextFieldMetrics {
2552            font_px,
2553            line_height_px: font_px,
2554            ..TextFieldMetrics::default()
2555        },
2556    )
2557}
2558
2559pub fn line_home_end_with_metrics(
2560    text: &str,
2561    wrap_w_px: f32,
2562    cur_byte: usize,
2563    to_end: bool,
2564    metrics: &TextFieldMetrics,
2565) -> usize {
2566    let layout = layout_text_area_with_metrics(text, wrap_w_px, metrics);
2567    let (line, _, _) = locate_byte_in_ranges(&layout.ranges, cur_byte);
2568    let (start, end) = layout.ranges.get(line).copied().unwrap_or((0, 0));
2569    // Wrapping trims line-edge whitespace, so a byte can sit in no range at all
2570    // (the space before a newline, or leading indentation). It has no line to go
2571    // to, so leave the caret alone rather than pulling it across the byte.
2572    if cur_byte < start || cur_byte > end {
2573        return cur_byte;
2574    }
2575    if to_end { end } else { start }
2576}
2577
2578/// Clamp to a valid cursor position: a grapheme-cluster boundary (which
2579/// implies a char boundary). The old version kept char boundaries, splitting
2580/// multi-char graphemes like 👍🏽 (U+1F44D U+1F3FD) or ZWJ sequences.
2581fn clamp_to_char_boundary(s: &str, i: usize) -> usize {
2582    if i >= s.len() {
2583        return s.len();
2584    }
2585    let mut j = i;
2586    while j > 0 && !s.is_char_boundary(j) {
2587        j -= 1;
2588    }
2589    if j == 0 || j == s.len() || is_grapheme_boundary(s, j) {
2590        return j;
2591    }
2592    prev_grapheme_boundary(s, j)
2593}
2594
2595/// The hint never scrolls, so a placeholder wider than the field is ellipsized
2596/// rather than left to the field clip, which would slice the last glyph.
2597fn hint_line(
2598    text_input: &TextInputConfig,
2599    metrics: &TextFieldMetrics,
2600    font_px: f32,
2601    max_width: f32,
2602) -> String {
2603    repose_text::ellipsize_line_with_family(
2604        &text_input.hint,
2605        font_px,
2606        max_width,
2607        metrics.font_family,
2608        metrics.font_weight,
2609        metrics.font_style,
2610        metrics.letter_spacing_px,
2611        metrics.font_variation_settings.as_deref(),
2612    )
2613}
2614
2615/// Paint a text field into the scene. Called by layout.rs when
2616/// `modifier.text_input.is_some()`. This is the Compose-equivalent of
2617/// `TextFieldCoreModifierNode.draw()` - the engine handles painting natively
2618/// when the text_input modifier is present (no caller-side painter needed).
2619///
2620/// Behavior per Compose BasicTextField:
2621/// - `text_input.enabled=false`: no cursor, no selection highlight, text rendered normally
2622/// - `text_input.read_only=true`: no cursor, selection highlight rendered
2623/// - `cursor_color`: overrides cursor brush
2624/// - `max_lines`: caps rendered lines (clip applied by container)
2625/// - `on_text_layout`: called after layout computation
2626pub(crate) fn paint_text_field(
2627    scene: &mut Scene,
2628    rect: repose_core::Rect,
2629    text_input: &TextInputConfig,
2630    state: Option<&Rc<RefCell<TextFieldState>>>,
2631    is_focused: bool,
2632    alpha_accum: f32,
2633) {
2634    let ts = text_input.text_style.clone().unwrap_or_default();
2635    let metrics = TextFieldMetrics::from_config(text_input);
2636    let font_val = metrics.font_px;
2637    let line_h = if text_input.multiline {
2638        metrics.line_height_px
2639    } else {
2640        font_val
2641    };
2642    let text_off_y = (rect.h - line_h.max(font_val)) / 2.0;
2643
2644    // Square: the box's own clip_rounded clip already rounds the border box, and
2645    // re-applying that radius here rounds the content box again, clipping the
2646    // first line and caret in the corners.
2647    scene.nodes.push(SceneNode::PushClip {
2648        rect,
2649        radius: [Px::ZERO; 4],
2650        op: repose_core::ClipOp::Intersect,
2651    });
2652
2653    let th = locals::theme();
2654    let show_selection = text_input.enabled;
2655    let show_cursor = text_input.enabled && !text_input.read_only;
2656    let cursor_color = text_input.cursor_color.unwrap_or(th.on_surface);
2657    let rendered_by_vt = |original: &str| -> String {
2658        if let Some(ref vt) = text_input.visual_transformation {
2659            let annotated = repose_core::AnnotatedString::new(original.to_string(), vec![]);
2660            vt.filter(&annotated).text.text
2661        } else {
2662            original.to_string()
2663        }
2664    };
2665
2666    if let Some(state_rc) = state {
2667        let st = state_rc.borrow();
2668
2669        if !text_input.multiline {
2670            // Single-line
2671            let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
2672                rendered_by_vt(&st.text)
2673            } else {
2674                st.text.clone()
2675            };
2676            let has_vt = text_input.visual_transformation.is_some();
2677            let m = measure_text(&measure_for, font_val, metrics.measure_config());
2678
2679            // Selection highlight
2680            if show_selection && st.selection.start != st.selection.end {
2681                let start_off = if has_vt {
2682                    original_offset_to_display_with_mapping(
2683                        &st.text,
2684                        &measure_for,
2685                        st.selection.start,
2686                        st.offset_map.as_deref(),
2687                    )
2688                } else {
2689                    st.selection.start
2690                };
2691                let end_off = if has_vt {
2692                    original_offset_to_display_with_mapping(
2693                        &st.text,
2694                        &measure_for,
2695                        st.selection.end,
2696                        st.offset_map.as_deref(),
2697                    )
2698                } else {
2699                    st.selection.end
2700                };
2701                let sx = m
2702                    .positions
2703                    .get(byte_to_char_index(&m, start_off))
2704                    .copied()
2705                    .unwrap_or(0.0)
2706                    - st.scroll_offset;
2707                let ex = m
2708                    .positions
2709                    .get(byte_to_char_index(&m, end_off))
2710                    .copied()
2711                    .unwrap_or(sx)
2712                    - st.scroll_offset;
2713                let selection = th.primary.with_alpha(SELECTION_BACKGROUND_ALPHA);
2714                let vis_x = sx.max(0.0);
2715                let vis_ex = ex.max(0.0);
2716                scene.nodes.push(SceneNode::Rect {
2717                    rect: repose_core::Rect {
2718                        x: rect.x + vis_x,
2719                        y: rect.y + text_off_y,
2720                        w: (vis_ex - vis_x).max(0.0),
2721                        h: line_h.max(font_val),
2722                    },
2723                    brush: Brush::Solid(selection),
2724                    radius: [Px::ZERO; 4],
2725                });
2726            }
2727
2728            // IME composition underline (visual feedback for an active preedit).
2729            if let Some(comp) = st.composition.clone() {
2730                let cs = if has_vt {
2731                    original_offset_to_display_with_mapping(
2732                        &st.text,
2733                        &measure_for,
2734                        comp.start,
2735                        st.offset_map.as_deref(),
2736                    )
2737                } else {
2738                    comp.start
2739                };
2740                let ce = if has_vt {
2741                    original_offset_to_display_with_mapping(
2742                        &st.text,
2743                        &measure_for,
2744                        comp.end,
2745                        st.offset_map.as_deref(),
2746                    )
2747                } else {
2748                    comp.end
2749                };
2750                let sx = m
2751                    .positions
2752                    .get(byte_to_char_index(&m, cs))
2753                    .copied()
2754                    .unwrap_or(0.0)
2755                    - st.scroll_offset;
2756                let ex = m
2757                    .positions
2758                    .get(byte_to_char_index(&m, ce))
2759                    .copied()
2760                    .unwrap_or(sx)
2761                    - st.scroll_offset;
2762                let y = rect.y + text_off_y + line_h.max(font_val) - Dp(2.0).to_px().0;
2763                scene.nodes.push(SceneNode::Rect {
2764                    rect: repose_core::Rect {
2765                        x: rect.x + sx.max(0.0),
2766                        y,
2767                        w: (ex - sx).max(Dp(2.0).to_px().0),
2768                        h: Dp(2.0).to_px().0,
2769                    },
2770                    brush: Brush::Solid(th.focus),
2771                    radius: [Px::ZERO; 4],
2772                });
2773            }
2774
2775            // Text
2776            let display_src = if st.text.is_empty() && !text_input.value.is_empty() {
2777                text_input.value.as_str()
2778            } else {
2779                st.text.as_str()
2780            };
2781            let txt_col = if display_src.is_empty() {
2782                ts.color.unwrap_or(th.on_surface_variant)
2783            } else {
2784                ts.color.unwrap_or(th.on_surface)
2785            };
2786            let render_txt = if display_src.is_empty() {
2787                hint_line(text_input, &metrics, font_val, rect.w)
2788            } else {
2789                rendered_by_vt(display_src)
2790            };
2791            scene.nodes.push(SceneNode::Text {
2792                rect: repose_core::Rect {
2793                    x: rect.x - st.scroll_offset,
2794                    y: rect.y + text_off_y,
2795                    w: rect.w,
2796                    h: line_h,
2797                },
2798                text: Arc::from(render_txt),
2799                color: mul_alpha_color(txt_col, alpha_accum),
2800                size: Px(font_val),
2801                style: TextPaintStyle {
2802                    font_family: metrics.font_family,
2803                    text_align: ts.text_align,
2804                    font_weight: FontWeight(metrics.font_weight),
2805                    font_style: match metrics.font_style {
2806                        1 => FontStyle::Italic,
2807                        _ => FontStyle::Normal,
2808                    },
2809                    text_decoration: ts.text_decoration.unwrap_or_default(),
2810                    letter_spacing: Px(metrics.letter_spacing_px),
2811                    line_height: Px(metrics.line_height_px),
2812                    font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
2813                    ..Default::default()
2814                },
2815            });
2816
2817            // Caret (only when enabled && !readOnly)
2818            if show_cursor
2819                && is_focused
2820                && st.selection.start == st.selection.end
2821                && st.caret_visible()
2822            {
2823                let caret_off = if has_vt {
2824                    original_offset_to_display_with_mapping(
2825                        &st.text,
2826                        &measure_for,
2827                        st.selection.end,
2828                        st.offset_map.as_deref(),
2829                    )
2830                } else {
2831                    st.selection.end
2832                };
2833                let cx = m
2834                    .positions
2835                    .get(byte_to_char_index(&m, caret_off))
2836                    .copied()
2837                    .unwrap_or(0.0)
2838                    - st.scroll_offset;
2839                let cursor_y = rect.y + text_off_y + (line_h.max(font_val) - font_val) / 2.0;
2840                scene.nodes.push(SceneNode::Rect {
2841                    rect: repose_core::Rect {
2842                        x: rect.x + cx.max(0.0),
2843                        y: cursor_y,
2844                        w: Dp(1.0).to_px().0,
2845                        h: font_val,
2846                    },
2847                    brush: Brush::Solid(cursor_color),
2848                    radius: [Px::ZERO; 4],
2849                });
2850            }
2851        } else {
2852            // Multi-line
2853            let render_text = if st.text.is_empty() {
2854                st.text.clone()
2855            } else if let Some(ref vt) = text_input.visual_transformation {
2856                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
2857                vt.filter(&annotated).text.text
2858            } else {
2859                st.text.clone()
2860            };
2861            let layout = layout_text_area_with_metrics(&render_text, rect.w.max(1.0), &metrics);
2862            let lh = layout.line_h_px;
2863            let max_line_count = text_input.max_lines.unwrap_or(usize::MAX);
2864            let visible_line_count = layout.ranges.len().min(max_line_count);
2865            let line_step = if lh.is_finite() && lh > 0.0 { lh } else { 1.0 };
2866            let first_visible_line = ((((st.scroll_offset_y - 1.0) / line_step).floor().max(0.0)
2867                as usize)
2868                .saturating_sub(1))
2869            .min(visible_line_count);
2870            let last_visible_line = (((st.scroll_offset_y + rect.h + 1.0) / line_step)
2871                .ceil()
2872                .max(0.0) as usize)
2873                .min(visible_line_count)
2874                .max(first_visible_line);
2875
2876            // Hint text (empty field)
2877            if st.text.is_empty() {
2878                scene.nodes.push(SceneNode::Text {
2879                    rect: repose_core::Rect {
2880                        x: rect.x,
2881                        y: rect.y,
2882                        w: rect.w,
2883                        h: line_h,
2884                    },
2885                    text: Arc::from(hint_line(text_input, &metrics, font_val, rect.w)),
2886                    color: mul_alpha_color(ts.color.unwrap_or(th.on_surface_variant), alpha_accum),
2887                    size: Px(font_val),
2888                    style: TextPaintStyle {
2889                        font_family: metrics.font_family,
2890                        text_align: ts.text_align,
2891                        font_weight: FontWeight(metrics.font_weight),
2892                        font_style: match metrics.font_style {
2893                            1 => FontStyle::Italic,
2894                            _ => FontStyle::Normal,
2895                        },
2896                        text_decoration: ts.text_decoration.unwrap_or_default(),
2897                        letter_spacing: Px(metrics.letter_spacing_px),
2898                        line_height: Px(metrics.line_height_px),
2899                        font_variation_settings: metrics
2900                            .font_variation_settings
2901                            .clone()
2902                            .map(Arc::from),
2903                        ..Default::default()
2904                    },
2905                });
2906            } else {
2907                for i in first_visible_line..last_visible_line {
2908                    let (s, e) = layout.ranges[i];
2909                    let ln = render_text[s..e].to_string();
2910                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
2911                    if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
2912                        continue;
2913                    }
2914                    scene.nodes.push(SceneNode::Text {
2915                        rect: repose_core::Rect {
2916                            x: rect.x,
2917                            y: draw_y,
2918                            w: rect.w,
2919                            h: lh,
2920                        },
2921                        text: Arc::<str>::from(ln),
2922                        color: mul_alpha_color(ts.color.unwrap_or(th.on_surface), alpha_accum),
2923                        size: Px(font_val),
2924                        style: TextPaintStyle {
2925                            font_family: metrics.font_family,
2926                            text_align: ts.text_align,
2927                            font_weight: FontWeight(metrics.font_weight),
2928                            font_style: match metrics.font_style {
2929                                1 => FontStyle::Italic,
2930                                _ => FontStyle::Normal,
2931                            },
2932                            text_decoration: ts.text_decoration.unwrap_or_default(),
2933                            letter_spacing: Px(metrics.letter_spacing_px),
2934                            line_height: Px(metrics.line_height_px),
2935                            font_variation_settings: metrics
2936                                .font_variation_settings
2937                                .clone()
2938                                .map(Arc::from),
2939                            ..Default::default()
2940                        },
2941                    });
2942                }
2943            }
2944
2945            // Selection (multi-line)
2946            if show_selection && st.selection.start != st.selection.end {
2947                let sel_a_orig: usize = st.selection.start.min(st.selection.end);
2948                let sel_b_orig: usize = st.selection.start.max(st.selection.end);
2949                let has_vt = text_input.visual_transformation.is_some();
2950                let sel_a = if has_vt {
2951                    original_offset_to_display_with_mapping(
2952                        &st.text,
2953                        &render_text,
2954                        sel_a_orig,
2955                        st.offset_map.as_deref(),
2956                    )
2957                } else {
2958                    sel_a_orig
2959                };
2960                let sel_b = if has_vt {
2961                    original_offset_to_display_with_mapping(
2962                        &st.text,
2963                        &render_text,
2964                        sel_b_orig,
2965                        st.offset_map.as_deref(),
2966                    )
2967                } else {
2968                    sel_b_orig
2969                };
2970                let measure_cfg = metrics.measure_config();
2971                let x_for = |line: &SelectionLine, byte: usize| -> f32 {
2972                    let m = measure_text(
2973                        &render_text[line.start..line.end],
2974                        font_val,
2975                        measure_cfg.clone(),
2976                    );
2977                    m.positions
2978                        .get(byte_to_char_index(&m, byte.saturating_sub(line.start)))
2979                        .copied()
2980                        .unwrap_or(0.0)
2981                };
2982                let sel_lines: Vec<SelectionLine> = (first_visible_line..last_visible_line)
2983                    .filter_map(|i| {
2984                        let &(start, end) = layout.ranges.get(i)?;
2985                        let line_w =
2986                            measure_text(&render_text[start..end], font_val, measure_cfg.clone())
2987                                .positions
2988                                .last()
2989                                .copied()
2990                                .unwrap_or(0.0);
2991                        Some(SelectionLine {
2992                            start,
2993                            end,
2994                            left: 0.0,
2995                            right: line_w,
2996                            top: (i as f32) * lh - st.scroll_offset_y,
2997                            height: lh,
2998                        })
2999                    })
3000                    .collect();
3001                let rects = selection_rects(&sel_lines, &(sel_a..sel_b), &x_for, (rect.x, rect.y));
3002                push_selection(scene, rects, selection_brush(th.primary));
3003            }
3004
3005            // IME composition underline (multi-line): intersect the preedit
3006            // range with each visible line and underline the overlapping span.
3007            if let Some(comp) = st.composition.clone() {
3008                let has_vt = text_input.visual_transformation.is_some();
3009                let comp_a = if has_vt {
3010                    original_offset_to_display_with_mapping(
3011                        &st.text,
3012                        &render_text,
3013                        comp.start,
3014                        st.offset_map.as_deref(),
3015                    )
3016                } else {
3017                    comp.start
3018                };
3019                let comp_b = if has_vt {
3020                    original_offset_to_display_with_mapping(
3021                        &st.text,
3022                        &render_text,
3023                        comp.end,
3024                        st.offset_map.as_deref(),
3025                    )
3026                } else {
3027                    comp.end
3028                };
3029                for i in first_visible_line..last_visible_line {
3030                    let (s, e) = layout.ranges[i];
3031                    let os = comp_a.max(s);
3032                    let oe = comp_b.min(e);
3033                    if os >= oe {
3034                        continue;
3035                    }
3036                    let ln = &render_text[s..e];
3037                    let m = measure_text(ln, font_val, metrics.measure_config());
3038                    let ls = os - s;
3039                    let le = oe - s;
3040                    let sx = m
3041                        .positions
3042                        .get(byte_to_char_index(&m, ls))
3043                        .copied()
3044                        .unwrap_or(0.0);
3045                    let ex = m
3046                        .positions
3047                        .get(byte_to_char_index(&m, le))
3048                        .copied()
3049                        .unwrap_or(sx);
3050                    let draw_y = rect.y + (i as f32) * lh - st.scroll_offset_y;
3051                    scene.nodes.push(SceneNode::Rect {
3052                        rect: repose_core::Rect {
3053                            x: rect.x + sx,
3054                            y: draw_y + lh - Dp(2.0).to_px().0,
3055                            w: (ex - sx).max(Dp(2.0).to_px().0),
3056                            h: Dp(2.0).to_px().0,
3057                        },
3058                        brush: Brush::Solid(th.focus),
3059                        radius: [Px::ZERO; 4],
3060                    });
3061                }
3062            }
3063
3064            // Caret (multi-line) - only when enabled && !readOnly
3065            if show_cursor
3066                && is_focused
3067                && st.selection.start == st.selection.end
3068                && st.caret_visible()
3069            {
3070                let caret_orig = st.selection.end.min(st.text.len());
3071                let has_vt = text_input.visual_transformation.is_some();
3072                let caret = if has_vt {
3073                    original_offset_to_display_with_mapping(
3074                        &st.text,
3075                        &render_text,
3076                        caret_orig,
3077                        st.offset_map.as_deref(),
3078                    )
3079                } else {
3080                    caret_orig
3081                };
3082                let (cx, cy, _li) =
3083                    caret_xy_for_byte_with_metrics(&render_text, rect.w.max(1.0), caret, &metrics);
3084                let draw_x = rect.x + cx;
3085                let draw_y = rect.y + cy - st.scroll_offset_y;
3086                scene.nodes.push(SceneNode::Rect {
3087                    rect: repose_core::Rect {
3088                        x: draw_x,
3089                        y: draw_y + (lh - font_val) / 2.0,
3090                        w: Dp(1.0).to_px().0,
3091                        h: font_val,
3092                    },
3093                    brush: Brush::Solid(cursor_color),
3094                    radius: [Px::ZERO; 4],
3095                });
3096            }
3097        }
3098    } else {
3099        // No state yet (unfocused) - render hint or raw value
3100        if text_input.value.is_empty() {
3101            let hint_y = if text_input.multiline {
3102                rect.y
3103            } else {
3104                rect.y + text_off_y
3105            };
3106            scene.nodes.push(SceneNode::Text {
3107                rect: repose_core::Rect {
3108                    x: rect.x,
3109                    y: hint_y,
3110                    w: rect.w,
3111                    h: line_h,
3112                },
3113                text: Arc::from(hint_line(text_input, &metrics, font_val, rect.w)),
3114                color: mul_alpha_color(th.on_surface_variant, alpha_accum),
3115                size: Px(font_val),
3116                style: TextPaintStyle {
3117                    font_family: metrics.font_family,
3118                    font_weight: FontWeight(metrics.font_weight),
3119                    font_style: if metrics.font_style == 1 {
3120                        FontStyle::Italic
3121                    } else {
3122                        FontStyle::Normal
3123                    },
3124                    text_decoration: ts.text_decoration.unwrap_or_default(),
3125                    letter_spacing: Px(metrics.letter_spacing_px),
3126                    line_height: Px(metrics.line_height_px),
3127                    font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
3128                    ..Default::default()
3129                },
3130            });
3131        } else if text_input.multiline {
3132            let render_text = if text_input.value.is_empty() {
3133                text_input.value.clone()
3134            } else if let Some(ref vt) = text_input.visual_transformation {
3135                let annotated = repose_core::AnnotatedString::new(text_input.value.clone(), vec![]);
3136                vt.filter(&annotated).text.text
3137            } else {
3138                text_input.value.clone()
3139            };
3140            let layout = layout_text_area_with_metrics(&render_text, rect.w.max(1.0), &metrics);
3141            let lh = layout.line_h_px;
3142            let line_step = if lh.is_finite() && lh > 0.0 { lh } else { 1.0 };
3143            let first_visible_line =
3144                ((-1.0 / line_step).floor().max(0.0) as usize).min(layout.ranges.len());
3145            let last_visible_line =
3146                ((rect.h + 1.0) / line_step).ceil().max(0.0) as usize + first_visible_line;
3147            for i in first_visible_line..last_visible_line.min(layout.ranges.len()) {
3148                let (s, e) = layout.ranges[i];
3149                let ln = render_text[s..e].to_string();
3150                let draw_y = rect.y + (i as f32) * lh;
3151                if draw_y + lh < rect.y - 1.0 || draw_y > rect.y + rect.h + 1.0 {
3152                    continue;
3153                }
3154                scene.nodes.push(SceneNode::Text {
3155                    rect: repose_core::Rect {
3156                        x: rect.x,
3157                        y: draw_y,
3158                        w: rect.w,
3159                        h: lh,
3160                    },
3161                    text: Arc::<str>::from(ln),
3162                    color: mul_alpha_color(th.on_surface, alpha_accum),
3163                    size: Px(font_val),
3164                    style: TextPaintStyle {
3165                        font_family: metrics.font_family,
3166                        font_weight: FontWeight(metrics.font_weight),
3167                        font_style: if metrics.font_style == 1 {
3168                            FontStyle::Italic
3169                        } else {
3170                            FontStyle::Normal
3171                        },
3172                        text_decoration: ts.text_decoration.unwrap_or_default(),
3173                        letter_spacing: Px(metrics.letter_spacing_px),
3174                        line_height: Px(metrics.line_height_px),
3175                        font_variation_settings: metrics
3176                            .font_variation_settings
3177                            .clone()
3178                            .map(Arc::from),
3179                        ..Default::default()
3180                    },
3181                });
3182            }
3183        } else {
3184            scene.nodes.push(SceneNode::Text {
3185                rect: repose_core::Rect {
3186                    x: rect.x,
3187                    y: rect.y + text_off_y,
3188                    w: rect.w,
3189                    h: line_h,
3190                },
3191                text: Arc::from(rendered_by_vt(&text_input.value)),
3192                color: mul_alpha_color(th.on_surface, alpha_accum),
3193                size: Px(font_val),
3194                style: TextPaintStyle {
3195                    font_family: metrics.font_family,
3196                    font_weight: FontWeight(metrics.font_weight),
3197                    font_style: if metrics.font_style == 1 {
3198                        FontStyle::Italic
3199                    } else {
3200                        FontStyle::Normal
3201                    },
3202                    text_decoration: ts.text_decoration.unwrap_or_default(),
3203                    letter_spacing: Px(metrics.letter_spacing_px),
3204                    line_height: Px(metrics.line_height_px),
3205                    font_variation_settings: metrics.font_variation_settings.clone().map(Arc::from),
3206                    ..Default::default()
3207                },
3208            });
3209        }
3210    }
3211
3212    // Fire on_text_layout callback with computed layout info
3213    if let Some(ref cb) = text_input.on_text_layout {
3214        let (
3215            line_count,
3216            content_w,
3217            content_h,
3218            first_baseline,
3219            last_baseline,
3220            did_overflow_w,
3221            did_overflow_h,
3222            lines,
3223        ) = if let Some(state_rc) = state {
3224            let st = state_rc.borrow();
3225            let display = if st.text.is_empty() {
3226                hint_line(text_input, &metrics, font_val, rect.w)
3227            } else if let Some(ref vt) = text_input.visual_transformation {
3228                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
3229                vt.filter(&annotated).text.text
3230            } else {
3231                st.text.clone()
3232            };
3233            if text_input.multiline {
3234                let l = layout_text_area_with_metrics(&display, rect.w.max(1.0), &metrics);
3235                let lc = l.ranges.len();
3236                let cw = rect.w.max(0.0);
3237                let ch = (lc as f32 * l.line_h_px).max(0.0);
3238                let line_infos: Vec<_> = l
3239                    .ranges
3240                    .iter()
3241                    .enumerate()
3242                    .map(|(i, &(s, e))| {
3243                        let top = i as f32 * l.line_h_px;
3244                        let bottom = top + l.line_h_px;
3245                        let line_text = &display[s..e];
3246                        let m = measure_text(line_text, font_val, metrics.measure_config());
3247                        let line_w = m.positions.last().copied().unwrap_or(0.0);
3248                        TextLineInfo {
3249                            start: s,
3250                            end: e,
3251                            top,
3252                            baseline: top + l.line_h_px * 0.8,
3253                            bottom,
3254                            left: 0.0,
3255                            right: line_w,
3256                            width: line_w,
3257                        }
3258                    })
3259                    .collect();
3260                let fb = line_infos.first().map(|l| l.baseline).unwrap_or(0.0);
3261                let lb = line_infos.last().map(|l| l.baseline).unwrap_or(0.0);
3262                (lc, cw, ch, fb, lb, cw > rect.w, ch > rect.h, line_infos)
3263            } else {
3264                let m = measure_text(&display, font_val, metrics.measure_config());
3265                let w = m.positions.last().copied().unwrap_or(0.0);
3266                let top = 0.0;
3267                let bottom = line_h.max(font_val);
3268                let baseline = bottom * 0.8;
3269                let line_info = TextLineInfo {
3270                    start: 0,
3271                    end: display.len(),
3272                    top,
3273                    baseline,
3274                    bottom,
3275                    left: 0.0,
3276                    right: w,
3277                    width: w,
3278                };
3279                (
3280                    1,
3281                    w.max(0.0),
3282                    bottom,
3283                    baseline,
3284                    baseline,
3285                    w > rect.w,
3286                    bottom > rect.h,
3287                    vec![line_info],
3288                )
3289            }
3290        } else {
3291            (0, 0.0, 0.0, 0.0, 0.0, false, false, vec![])
3292        };
3293        cb(&repose_core::TextLayoutResult {
3294            line_count,
3295            width_px: content_w,
3296            height_px: content_h,
3297            first_baseline,
3298            last_baseline,
3299            did_overflow_width: did_overflow_w,
3300            did_overflow_height: did_overflow_h,
3301            lines,
3302        });
3303    }
3304
3305    scene.nodes.push(SceneNode::PopClip);
3306}
3307
3308/// Shared view-builder for `BasicTextField`.
3309/// View with text_input modifier. Painting is handled natively
3310/// by layout.rs when it encounters `modifier.text_input` (Compose-aligned).
3311fn text_field_view(
3312    modifier: Modifier,
3313    hint: String,
3314    value: String,
3315    multiline: bool,
3316    on_change: Option<Rc<dyn Fn(String)>>,
3317    on_submit: Option<Rc<dyn Fn(String)>>,
3318    visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
3319    keyboard_type: repose_core::KeyboardType,
3320    capitalization: repose_core::KeyboardCapitalization,
3321    ime_action: repose_core::ImeAction,
3322    auto_correct_enabled: Option<bool>,
3323    enabled: bool,
3324    read_only: bool,
3325    max_lines: Option<usize>,
3326    min_lines: usize,
3327    cursor_color: Option<Color>,
3328    on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
3329    text_style: repose_core::TextStyle,
3330    keyboard_actions: repose_core::KeyboardActions,
3331    interaction_source: Option<repose_core::MutableInteractionSource>,
3332    focus_tracker: Option<Rc<Cell<bool>>>,
3333    line_limits: Option<repose_core::TextFieldLineLimits>,
3334    decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
3335) -> View {
3336    let mut modif = modifier.text_input(TextInputConfig {
3337        hint,
3338        multiline,
3339        on_change,
3340        on_submit,
3341        focus_tracker,
3342        value,
3343        visual_transformation,
3344        keyboard_type,
3345        capitalization,
3346        ime_action,
3347        auto_correct_enabled,
3348        enabled,
3349        read_only,
3350        sensitive: matches!(
3351            keyboard_type,
3352            repose_core::KeyboardType::Password
3353                | repose_core::KeyboardType::NumberPassword
3354                | repose_core::KeyboardType::DecimalPassword
3355                | repose_core::KeyboardType::NumberPasswordSigned
3356                | repose_core::KeyboardType::DecimalPasswordSigned
3357        ),
3358        max_lines,
3359        min_lines,
3360        cursor_color,
3361        on_text_layout,
3362        text_style: Some(text_style),
3363        keyboard_actions: Some(keyboard_actions),
3364        interaction_source: interaction_source.as_ref().map(|s| s.source()),
3365        line_limits,
3366    });
3367
3368    // `enabled=false` => not focusable and inert;
3369    // `read_only` keeps focus for selection/copy but blocks mutation.
3370    if !enabled {
3371        modif = modif.focusable(false).enabled(false);
3372    } else if read_only {
3373        modif = modif.focusable(true);
3374    }
3375
3376    let inner = View::new(0, ViewKind::Box)
3377        .modifier(modif)
3378        .semantics(Semantics {
3379            role: Role::TextField,
3380            enabled,
3381            ..Default::default()
3382        });
3383
3384    // Compose `decorationBox`: wrap the field node when provided.
3385    if let Some(decorate) = decoration_box {
3386        decorate(inner)
3387    } else {
3388        inner
3389    }
3390}
3391
3392/// Ensure caret visibility for a TextFieldState inside a given rect (px).
3393/// Extracted from `repose-platform::tf_ensure_visible_in_rect` for better layering.
3394pub fn tf_ensure_visible_in_rect(state: &mut TextFieldState, inner_rect: repose_core::Rect) {
3395    use crate::textfield::{TF_PADDING_X, TextFieldMetrics};
3396    let metrics = TextFieldMetrics::default();
3397    let caret = state.caret_index();
3398    let (display, display_caret) = if let Some(transformation) = &state.visual_transformation {
3399        let annotated = repose_core::AnnotatedString::new(state.text.clone(), vec![]);
3400        let transformed = transformation.filter(&annotated);
3401        (
3402            transformed.text.text,
3403            transformed.offset_mapping.original_to_transformed(caret),
3404        )
3405    } else {
3406        (state.text.clone(), caret)
3407    };
3408    let measured = measure_text(&display, metrics.font_px, metrics.measure_config());
3409    let caret_x_px = measured
3410        .positions
3411        .get(byte_to_char_index(&measured, display_caret))
3412        .copied()
3413        .unwrap_or(0.0);
3414    state.ensure_caret_visible(
3415        caret_x_px,
3416        inner_rect.w - 2.0 * TF_PADDING_X.to_px().0,
3417        Dp(2.0).to_px().0,
3418    );
3419}
3420
3421#[cfg(test)]
3422mod tests {
3423    use super::*;
3424
3425    #[test]
3426    fn test_index_for_x_bytes_grapheme() {
3427        let t = "A👍🏽B";
3428        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
3429        let m = measure_text(t, font_px, TextMeasureConfig::default());
3430        for i in 0..m.byte_offsets.len() - 1 {
3431            let b = m.byte_offsets[i];
3432            let _ = &t[..b];
3433        }
3434    }
3435
3436    fn delete_op(
3437        index: usize,
3438        pre_text: &str,
3439        pre_selection: Range<usize>,
3440        post_selection: Range<usize>,
3441    ) -> TextUndoOp {
3442        TextUndoOp {
3443            index,
3444            pre_text: pre_text.to_string(),
3445            post_text: String::new(),
3446            pre_selection,
3447            post_selection,
3448            time: Instant::now(),
3449            can_merge: true,
3450        }
3451    }
3452
3453    #[test]
3454    fn deletion_type_collapsed_post_selection_is_backspace() {
3455        // Backspace on "abc" with cursor at 3 deletes 'c', cursor moves to 2.
3456        let op = delete_op(2, "c", 3..3, 2..2);
3457        assert_eq!(op.deletion_type(), TextDeleteType::Start);
3458    }
3459
3460    #[test]
3461    fn deletion_type_collapsed_post_selection_is_delete_forward() {
3462        // Delete-forward at cursor 3 removes 'c' but the cursor stays put.
3463        let op = delete_op(3, "c", 3..3, 3..3);
3464        assert_eq!(op.deletion_type(), TextDeleteType::End);
3465    }
3466
3467    #[test]
3468    fn deletion_type_range_post_selection_is_not_by_user() {
3469        // A deletion that leaves an expanded post-selection is not a plain
3470        // backspace/delete-forward and must never merge (regression for the
3471        // old `!start == end` precedence bug which compared bitwise-not of start).
3472        let op = delete_op(2, "de", 2..4, 3..5);
3473        assert_eq!(op.deletion_type(), TextDeleteType::NotByUser);
3474    }
3475
3476    #[test]
3477    fn backspace_ops_merge() {
3478        // "abc": cursor 3 -> 2 -> 1 via two backspaces merges into one "bc" delete.
3479        let a = delete_op(2, "c", 3..3, 2..2);
3480        let b = delete_op(1, "b", 2..2, 1..1);
3481        let merged = a
3482            .try_merge(&b)
3483            .expect("consecutive backspaces should merge");
3484        assert_eq!(merged.index, 1);
3485        assert_eq!(merged.pre_text, "bc");
3486    }
3487
3488    #[test]
3489    fn selection_delete_does_not_merge_with_backspace() {
3490        // Selection-delete classifies as Inner, so it never merges with a
3491        // Start/End backspace-merge even back-to-back.
3492        let backspace = delete_op(2, "c", 3..3, 2..2);
3493        let selection = delete_op(2, "de", 2..4, 2..2);
3494        assert_eq!(selection.deletion_type(), TextDeleteType::Inner);
3495        assert!(backspace.try_merge(&selection).is_none());
3496    }
3497
3498    /// Tapping a line must land on the byte under the finger, and putting the
3499    /// caret on a byte must report that byte's own line. Both directions share
3500    /// `locate_byte_in_ranges`, so a mistake there shows the caret snapping to
3501    /// a line's first or last letter.
3502    ///
3503    /// Asserts the raw line index from `caret_xy_for_byte_with_metrics` rather
3504    /// than re-deriving it from the y coordinate, so an off-by-one line or a
3505    /// caret drawn on the neighbouring line cannot pass.
3506    #[test]
3507    fn multiline_tap_and_caret_round_trip() {
3508        let m = TextFieldMetrics::default();
3509        let lh = m.line_height_px;
3510        for (text, wrap) in [
3511            ("hello world\nsecond line here\nthird", 1000.0),
3512            ("hello world\nsecond line here\nthird", 60.0),
3513            ("a\n\nb", 1000.0),
3514            ("one\n", 1000.0),
3515            ("日本語テスト二行目", 30.0),
3516            ("héllo wörld\nünicode", 1000.0),
3517        ] {
3518            let layout = layout_text_area_with_metrics(text, wrap, &m);
3519            for b in 0..=text.len() {
3520                if !text.is_char_boundary(b) {
3521                    continue;
3522                }
3523                let (x, y, line) = caret_xy_for_byte_with_metrics(text, wrap, b, &m);
3524                let Some(&(line_start, line_end)) = layout.ranges.get(line) else {
3525                    panic!("byte {b} of {text:?} reported absent line {line}");
3526                };
3527                assert!(
3528                    line_start <= b && b <= line_end,
3529                    "byte {b} of {text:?} reported line {line} [{line_start}..{line_end}]"
3530                );
3531                let got = index_for_xy_bytes_with_metrics(text, wrap, x + 0.5, y + lh * 0.5, &m);
3532                assert_eq!(got, b, "byte {b} of {text:?} at ({x}, {y}) tapped to {got}");
3533            }
3534        }
3535    }
3536
3537    /// Wrapping drops line-edge whitespace, so those bytes belong to no line.
3538    /// Home and End must not drag the caret across them.
3539    #[test]
3540    fn home_end_never_move_caret_against_the_user() {
3541        let m = TextFieldMetrics::default();
3542        for text in ["ab \ncd", "ab  \ncd", "   indented line\nnext", "one\n"] {
3543            for wrap in [1000.0f32, 30.0] {
3544                let ranges = layout_text_area_with_metrics(text, wrap, &m).ranges;
3545                for b in 0..=text.len() {
3546                    let (line, _, _) = locate_byte_in_ranges(&ranges, b);
3547                    let (start, end) = ranges.get(line).copied().unwrap_or((b, b));
3548                    let to_end = line_home_end_with_metrics(text, wrap, b, true, &m);
3549                    let to_home = line_home_end_with_metrics(text, wrap, b, false, &m);
3550                    if start <= b && b <= end {
3551                        assert_eq!(to_end, end, "End from {b} in {text:?} wrap {wrap}");
3552                        assert_eq!(to_home, start, "Home from {b} in {text:?} wrap {wrap}");
3553                    } else {
3554                        assert_eq!(to_end, b, "End from gap byte {b} in {text:?} wrap {wrap}");
3555                        assert_eq!(to_home, b, "Home from gap byte {b} in {text:?} wrap {wrap}");
3556                    }
3557                }
3558            }
3559        }
3560    }
3561}