Skip to main content

repose_ui/
textfield.rs

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