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 pad_x = dp_to_px(TF_PADDING_X_DP);
1370    let inner = repose_core::Rect {
1371        x: rect.x + pad_x,
1372        y: rect.y + dp_to_px(8.0),
1373        w: (rect.w - 2.0 * pad_x).max(0.0),
1374        h: (rect.h - dp_to_px(16.0)).max(0.0),
1375    };
1376
1377    let ts = text_input
1378        .text_style
1379        .as_ref()
1380        .map(|s| s.clone())
1381        .unwrap_or_default();
1382    let font_size_dp = if ts.font_size != 0.0 {
1383        ts.font_size
1384    } else {
1385        TF_FONT_DP
1386    };
1387    let font_val = dp_to_px(font_size_dp) * locals::text_scale().0;
1388    let line_h = if ts.line_height != 0.0 {
1389        dp_to_px(ts.line_height)
1390    } else {
1391        font_val * 1.3
1392    };
1393    let text_off_y = (inner.h - line_h) / 2.0;
1394
1395    if is_focused {
1396        let radius = clip_rounded.unwrap_or([4.0; 4]).map(dp_to_px);
1397        scene.nodes.push(SceneNode::Border {
1398            rect,
1399            color: locals::theme().focus,
1400            width: dp_to_px(2.0),
1401            radius,
1402        });
1403    }
1404
1405    scene.nodes.push(SceneNode::PushClip {
1406        rect: inner,
1407        radius: [0.0; 4],
1408    });
1409
1410    let th = locals::theme();
1411    let show_selection = text_input.enabled;
1412    let show_cursor = text_input.enabled && !text_input.read_only;
1413    let cursor_color = text_input.cursor_color.unwrap_or(th.on_surface);
1414    let rendered_by_vt = |original: &str| -> String {
1415        if let Some(ref vt) = text_input.visual_transformation {
1416            let annotated = repose_core::AnnotatedString::new(original.to_string(), vec![]);
1417            vt.filter(&annotated).text.text
1418        } else {
1419            original.to_string()
1420        }
1421    };
1422
1423    if let Some(state_rc) = state {
1424        let st = state_rc.borrow();
1425
1426        if !text_input.multiline {
1427            // Single-line
1428            let measure_for = if text_input.visual_transformation.is_some() && !st.text.is_empty() {
1429                rendered_by_vt(&st.text)
1430            } else {
1431                st.text.clone()
1432            };
1433            let has_vt = text_input.visual_transformation.is_some();
1434            let m = measure_text(
1435                &measure_for,
1436                font_val,
1437                ts.font_family,
1438                ts.font_weight.unwrap_or(400),
1439                ts.font_style.unwrap_or(0),
1440            );
1441
1442            // Selection highlight
1443            if show_selection && st.selection.start != st.selection.end {
1444                let start_off = if has_vt {
1445                    original_offset_to_display(&st.text, &measure_for, st.selection.start)
1446                } else {
1447                    st.selection.start
1448                };
1449                let end_off = if has_vt {
1450                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1451                } else {
1452                    st.selection.end
1453                };
1454                let sx = m
1455                    .positions
1456                    .get(byte_to_char_index(&m, start_off))
1457                    .copied()
1458                    .unwrap_or(0.0)
1459                    - st.scroll_offset;
1460                let ex = m
1461                    .positions
1462                    .get(byte_to_char_index(&m, end_off))
1463                    .copied()
1464                    .unwrap_or(sx)
1465                    - st.scroll_offset;
1466                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1467                let vis_x = sx.max(0.0);
1468                let vis_ex = ex.max(0.0);
1469                scene.nodes.push(SceneNode::Rect {
1470                    rect: repose_core::Rect {
1471                        x: inner.x + vis_x,
1472                        y: inner.y + text_off_y,
1473                        w: (vis_ex - vis_x).max(0.0),
1474                        h: line_h,
1475                    },
1476                    brush: Brush::Solid(selection),
1477                    radius: [0.0; 4],
1478                });
1479            }
1480
1481            // Text
1482            let txt_col = if st.text.is_empty() {
1483                ts.color.unwrap_or(th.on_surface_variant)
1484            } else {
1485                ts.color.unwrap_or(th.on_surface)
1486            };
1487            let render_txt = if st.text.is_empty() {
1488                text_input.hint.clone()
1489            } else {
1490                rendered_by_vt(&st.text)
1491            };
1492            scene.nodes.push(SceneNode::Text {
1493                rect: repose_core::Rect {
1494                    x: inner.x - st.scroll_offset,
1495                    y: inner.y + text_off_y,
1496                    w: inner.w,
1497                    h: line_h,
1498                },
1499                text: Arc::from(render_txt),
1500                color: txt_col,
1501                size: font_val,
1502                font_family: ts.font_family,
1503                text_align: ts.text_align,
1504                font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1505                font_style: match ts.font_style.unwrap_or(0) {
1506                    1 => FontStyle::Italic,
1507                    _ => FontStyle::Normal,
1508                },
1509                text_decoration: ts.text_decoration.unwrap_or_default(),
1510                letter_spacing: ts.letter_spacing,
1511                line_height: ts.line_height,
1512            });
1513
1514            // Caret (only when enabled && !readOnly)
1515            if show_cursor
1516                && is_focused
1517                && st.selection.start == st.selection.end
1518                && st.caret_visible()
1519            {
1520                let caret_off = if has_vt {
1521                    original_offset_to_display(&st.text, &measure_for, st.selection.end)
1522                } else {
1523                    st.selection.end
1524                };
1525                let cx = m
1526                    .positions
1527                    .get(byte_to_char_index(&m, caret_off))
1528                    .copied()
1529                    .unwrap_or(0.0)
1530                    - st.scroll_offset;
1531                let cursor_y = inner.y + text_off_y + (line_h - font_val) / 2.0;
1532                scene.nodes.push(SceneNode::Rect {
1533                    rect: repose_core::Rect {
1534                        x: inner.x + cx.max(0.0),
1535                        y: cursor_y,
1536                        w: dp_to_px(1.0),
1537                        h: font_val,
1538                    },
1539                    brush: Brush::Solid(cursor_color),
1540                    radius: [0.0; 4],
1541                });
1542            }
1543        } else {
1544            // Multi-line
1545            let render_text = if st.text.is_empty() {
1546                st.text.clone()
1547            } else if let Some(ref vt) = text_input.visual_transformation {
1548                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
1549                vt.filter(&annotated).text.text
1550            } else {
1551                st.text.clone()
1552            };
1553            let layout = layout_text_area(&render_text, font_val, inner.w.max(1.0), 400, 0);
1554            let lh = layout.line_h_px;
1555            let max_line_count = text_input.max_lines.unwrap_or(usize::MAX);
1556
1557            // Hint text (empty field)
1558            if st.text.is_empty() {
1559                scene.nodes.push(SceneNode::Text {
1560                    rect: repose_core::Rect {
1561                        x: inner.x,
1562                        y: inner.y,
1563                        w: inner.w,
1564                        h: inner.h,
1565                    },
1566                    text: Arc::from(text_input.hint.clone()),
1567                    color: ts.color.unwrap_or(th.on_surface_variant),
1568                    size: font_val,
1569                    font_family: ts.font_family,
1570                    text_align: ts.text_align,
1571                    font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1572                    font_style: match ts.font_style.unwrap_or(0) {
1573                        1 => FontStyle::Italic,
1574                        _ => FontStyle::Normal,
1575                    },
1576                    text_decoration: ts.text_decoration.unwrap_or_default(),
1577                    letter_spacing: ts.letter_spacing,
1578                    line_height: ts.line_height,
1579                });
1580            } else {
1581                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1582                    if i >= max_line_count {
1583                        break;
1584                    }
1585                    let ln = render_text[s..e].to_string();
1586                    let draw_y = inner.y + (i as f32) * lh - st.scroll_offset_y;
1587                    if draw_y + lh < inner.y - 1.0 || draw_y > inner.y + inner.h + 1.0 {
1588                        continue;
1589                    }
1590                    scene.nodes.push(SceneNode::Text {
1591                        rect: repose_core::Rect {
1592                            x: inner.x,
1593                            y: draw_y,
1594                            w: inner.w,
1595                            h: lh,
1596                        },
1597                        text: Arc::<str>::from(ln),
1598                        color: ts.color.unwrap_or(th.on_surface),
1599                        size: font_val,
1600                        font_family: ts.font_family,
1601                        text_align: ts.text_align,
1602                        font_weight: FontWeight(ts.font_weight.unwrap_or(400)),
1603                        font_style: match ts.font_style.unwrap_or(0) {
1604                            1 => FontStyle::Italic,
1605                            _ => FontStyle::Normal,
1606                        },
1607                        text_decoration: ts.text_decoration.unwrap_or_default(),
1608                        letter_spacing: ts.letter_spacing,
1609                        line_height: ts.line_height,
1610                    });
1611                }
1612            }
1613
1614            // Selection (multi-line)
1615            if show_selection && st.selection.start != st.selection.end {
1616                let sel_a_orig: usize = st.selection.start.min(st.selection.end);
1617                let sel_b_orig: usize = st.selection.start.max(st.selection.end);
1618                let has_vt = text_input.visual_transformation.is_some();
1619                let sel_a = if has_vt {
1620                    original_offset_to_display(&st.text, &render_text, sel_a_orig)
1621                } else {
1622                    sel_a_orig
1623                };
1624                let sel_b = if has_vt {
1625                    original_offset_to_display(&st.text, &render_text, sel_b_orig)
1626                } else {
1627                    sel_b_orig
1628                };
1629                let selection = th.focus.with_alpha_f32(85.0 / 255.0);
1630                for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1631                    if i >= max_line_count {
1632                        break;
1633                    }
1634                    let os = sel_a.max(s);
1635                    let oe = sel_b.min(e);
1636                    if os >= oe {
1637                        continue;
1638                    }
1639                    let ln = &render_text[s..e];
1640                    let m = measure_text(
1641                        ln,
1642                        font_val,
1643                        ts.font_family,
1644                        ts.font_weight.unwrap_or(400),
1645                        ts.font_style.unwrap_or(0),
1646                    );
1647                    let ls = os - s;
1648                    let le = oe - s;
1649                    let sx = m
1650                        .positions
1651                        .get(byte_to_char_index(&m, ls))
1652                        .copied()
1653                        .unwrap_or(0.0);
1654                    let ex = m
1655                        .positions
1656                        .get(byte_to_char_index(&m, le))
1657                        .copied()
1658                        .unwrap_or(sx);
1659                    let draw_y = inner.y + (i as f32) * lh - st.scroll_offset_y;
1660                    scene.nodes.push(SceneNode::Rect {
1661                        rect: repose_core::Rect {
1662                            x: inner.x + sx,
1663                            y: draw_y,
1664                            w: (ex - sx).max(0.0),
1665                            h: lh,
1666                        },
1667                        brush: Brush::Solid(selection),
1668                        radius: [0.0; 4],
1669                    });
1670                }
1671            }
1672
1673            // Caret (multi-line) - only when enabled && !readOnly
1674            if show_cursor
1675                && is_focused
1676                && st.selection.start == st.selection.end
1677                && st.caret_visible()
1678            {
1679                let caret_orig = st.selection.end.min(st.text.len());
1680                let has_vt = text_input.visual_transformation.is_some();
1681                let caret = if has_vt {
1682                    original_offset_to_display(&st.text, &render_text, caret_orig)
1683                } else {
1684                    caret_orig
1685                };
1686                let (cx, cy, _li) =
1687                    caret_xy_for_byte(&render_text, font_val, inner.w.max(1.0), caret);
1688                let draw_x = inner.x + cx;
1689                let draw_y = inner.y + cy - st.scroll_offset_y;
1690                scene.nodes.push(SceneNode::Rect {
1691                    rect: repose_core::Rect {
1692                        x: draw_x,
1693                        y: draw_y + (lh - font_val) / 2.0,
1694                        w: dp_to_px(1.0),
1695                        h: font_val,
1696                    },
1697                    brush: Brush::Solid(cursor_color),
1698                    radius: [0.0; 4],
1699                });
1700            }
1701        }
1702    } else {
1703        // No state yet (unfocused) - render hint or raw value
1704        if text_input.value.is_empty() {
1705            let hint_y = if text_input.multiline {
1706                inner.y
1707            } else {
1708                inner.y + text_off_y
1709            };
1710            scene.nodes.push(SceneNode::Text {
1711                rect: repose_core::Rect {
1712                    x: inner.x,
1713                    y: hint_y,
1714                    w: inner.w,
1715                    h: if text_input.multiline {
1716                        inner.h
1717                    } else {
1718                        line_h
1719                    },
1720                },
1721                text: Arc::from(text_input.hint.clone()),
1722                color: th.on_surface_variant,
1723                size: font_val,
1724                font_family: None,
1725                text_align: TextAlign::Unspecified,
1726                font_weight: FontWeight::NORMAL,
1727                font_style: FontStyle::Normal,
1728                text_decoration: ts.text_decoration.unwrap_or_default(),
1729                letter_spacing: 0.0,
1730                line_height: 0.0,
1731            });
1732        } else if text_input.multiline {
1733            let render_text = if text_input.value.is_empty() {
1734                text_input.value.clone()
1735            } else if let Some(ref vt) = text_input.visual_transformation {
1736                let annotated = repose_core::AnnotatedString::new(text_input.value.clone(), vec![]);
1737                vt.filter(&annotated).text.text
1738            } else {
1739                text_input.value.clone()
1740            };
1741            let layout = layout_text_area(&render_text, font_val, inner.w.max(1.0), 400, 0);
1742            let lh = layout.line_h_px;
1743            for (i, (s, e)) in layout.ranges.iter().copied().enumerate() {
1744                let ln = render_text[s..e].to_string();
1745                let draw_y = inner.y + (i as f32) * lh;
1746                if draw_y + lh < inner.y - 1.0 || draw_y > inner.y + inner.h + 1.0 {
1747                    continue;
1748                }
1749                scene.nodes.push(SceneNode::Text {
1750                    rect: repose_core::Rect {
1751                        x: inner.x,
1752                        y: draw_y,
1753                        w: inner.w,
1754                        h: lh,
1755                    },
1756                    text: Arc::<str>::from(ln),
1757                    color: th.on_surface,
1758                    size: font_val,
1759                    font_family: None,
1760                    text_align: TextAlign::Unspecified,
1761                    font_weight: FontWeight::NORMAL,
1762                    font_style: FontStyle::Normal,
1763                    text_decoration: ts.text_decoration.unwrap_or_default(),
1764                    letter_spacing: 0.0,
1765                    line_height: 0.0,
1766                });
1767            }
1768        } else {
1769            scene.nodes.push(SceneNode::Text {
1770                rect: repose_core::Rect {
1771                    x: inner.x,
1772                    y: inner.y + text_off_y,
1773                    w: inner.w,
1774                    h: line_h,
1775                },
1776                text: Arc::from(rendered_by_vt(&text_input.value)),
1777                color: th.on_surface,
1778                size: font_val,
1779                font_family: None,
1780                text_align: TextAlign::Unspecified,
1781                font_weight: FontWeight::NORMAL,
1782                font_style: FontStyle::Normal,
1783                text_decoration: ts.text_decoration.unwrap_or_default(),
1784                letter_spacing: 0.0,
1785                line_height: 0.0,
1786            });
1787        }
1788    }
1789
1790    // Fire on_text_layout callback with computed layout info
1791    if let Some(ref cb) = text_input.on_text_layout {
1792        let (
1793            line_count,
1794            content_w,
1795            content_h,
1796            first_baseline,
1797            last_baseline,
1798            did_overflow_w,
1799            did_overflow_h,
1800            lines,
1801        ) = if let Some(state_rc) = state {
1802            let st = state_rc.borrow();
1803            let display = if st.text.is_empty() {
1804                text_input.hint.clone()
1805            } else if let Some(ref vt) = text_input.visual_transformation {
1806                let annotated = repose_core::AnnotatedString::new(st.text.clone(), vec![]);
1807                vt.filter(&annotated).text.text
1808            } else {
1809                st.text.clone()
1810            };
1811            if text_input.multiline {
1812                let l = layout_text_area(&display, font_val, inner.w.max(1.0), 400, 0);
1813                let lc = l.ranges.len();
1814                let cw = inner.w.max(0.0);
1815                let ch = (lc as f32 * l.line_h_px).max(0.0);
1816                let line_infos: Vec<_> = l
1817                    .ranges
1818                    .iter()
1819                    .enumerate()
1820                    .map(|(i, &(s, e))| {
1821                        let top = i as f32 * l.line_h_px;
1822                        let bottom = top + l.line_h_px;
1823                        let line_text = &display[s..e];
1824                        let m = measure_text(line_text, font_val, None, 400, 0);
1825                        let line_w = m.positions.last().copied().unwrap_or(0.0);
1826                        TextLineInfo {
1827                            start: s,
1828                            end: e,
1829                            top,
1830                            baseline: top + l.line_h_px * 0.8,
1831                            bottom,
1832                            left: 0.0,
1833                            right: line_w,
1834                            width: line_w,
1835                        }
1836                    })
1837                    .collect();
1838                let fb = line_infos.first().map(|l| l.baseline).unwrap_or(0.0);
1839                let lb = line_infos.last().map(|l| l.baseline).unwrap_or(0.0);
1840                (lc, cw, ch, fb, lb, cw > inner.w, ch > inner.h, line_infos)
1841            } else {
1842                let m = measure_text(&display, font_val, None, 400, 0);
1843                let w = m.positions.last().copied().unwrap_or(0.0);
1844                let top = 0.0;
1845                let bottom = line_h;
1846                let baseline = line_h * 0.8;
1847                let line_info = TextLineInfo {
1848                    start: 0,
1849                    end: display.len(),
1850                    top,
1851                    baseline,
1852                    bottom,
1853                    left: 0.0,
1854                    right: w,
1855                    width: w,
1856                };
1857                (
1858                    1,
1859                    w.max(0.0),
1860                    line_h.max(0.0),
1861                    baseline,
1862                    baseline,
1863                    w > inner.w,
1864                    line_h > inner.h,
1865                    vec![line_info],
1866                )
1867            }
1868        } else {
1869            (0, 0.0, 0.0, 0.0, 0.0, false, false, vec![])
1870        };
1871        cb(&repose_core::TextLayoutResult {
1872            line_count,
1873            width_px: content_w,
1874            height_px: content_h,
1875            first_baseline,
1876            last_baseline,
1877            did_overflow_width: did_overflow_w,
1878            did_overflow_height: did_overflow_h,
1879            lines,
1880        });
1881    }
1882
1883    scene.nodes.push(SceneNode::PopClip);
1884}
1885
1886/// Shared view-builder for `BasicTextField`.
1887/// Creates the view with text_input modifier. Painting is handled natively
1888/// by layout.rs when it encounters `modifier.text_input` (Compose-aligned).
1889fn text_field_view(
1890    modifier: Modifier,
1891    hint: String,
1892    value: String,
1893    multiline: bool,
1894    on_change: Option<Rc<dyn Fn(String)>>,
1895    on_submit: Option<Rc<dyn Fn(String)>>,
1896    visual_transformation: Option<Rc<dyn repose_core::VisualTransformation>>,
1897    keyboard_type: repose_core::KeyboardType,
1898    capitalization: repose_core::KeyboardCapitalization,
1899    ime_action: repose_core::ImeAction,
1900    enabled: bool,
1901    read_only: bool,
1902    max_lines: Option<usize>,
1903    min_lines: usize,
1904    cursor_color: Option<Color>,
1905    on_text_layout: Option<Rc<dyn Fn(&repose_core::TextLayoutResult)>>,
1906    text_style: repose_core::TextStyle,
1907    keyboard_actions: repose_core::KeyboardActions,
1908    interaction_source: Option<repose_core::MutableInteractionSource>,
1909    line_limits: Option<repose_core::TextFieldLineLimits>,
1910    _input_transformation: Option<Rc<dyn repose_core::InputTransformation>>,
1911    _output_transformation: Option<Rc<dyn repose_core::OutputTransformation>>,
1912    _decoration_box: Option<Rc<dyn Fn(repose_core::View) -> repose_core::View>>,
1913    _codepoint_transformation: Option<repose_core::CodepointTransformation>,
1914) -> View {
1915    let modif = modifier.text_input(TextInputConfig {
1916        hint,
1917        multiline,
1918        on_change,
1919        on_submit,
1920        focus_tracker: None,
1921        value,
1922        visual_transformation,
1923        keyboard_type,
1924        capitalization,
1925        ime_action,
1926        enabled,
1927        read_only,
1928        max_lines,
1929        min_lines,
1930        cursor_color,
1931        on_text_layout,
1932        text_style: Some(text_style),
1933        keyboard_actions: Some(keyboard_actions),
1934        interaction_source: interaction_source.as_ref().map(|s| s.source()),
1935        line_limits,
1936    });
1937
1938    View::new(0, ViewKind::Box)
1939        .modifier(modif)
1940        .semantics(Semantics {
1941            role: Role::TextField,
1942            label: None,
1943            focused: false,
1944            enabled,
1945            selectable_group: false,
1946        })
1947}
1948
1949#[cfg(test)]
1950mod tests {
1951    use super::*;
1952
1953    #[test]
1954    fn test_index_for_x_bytes_grapheme() {
1955        let t = "A👍🏽B";
1956        let font_px = 16.0; // in tests, exact px isn't important-boundaries are.
1957        let m = measure_text(t, font_px, None, 400, 0);
1958        for i in 0..m.byte_offsets.len() - 1 {
1959            let b = m.byte_offsets[i];
1960            let _ = &t[..b];
1961        }
1962    }
1963}