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