Skip to main content

orinium_browser/engine/ui/components/
input_text.rs

1//! Editable single-line text input with IME composition state.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use smol_str::SmolStr;
8use ui_layout::Style;
9
10use crate::engine::bridge::text::{self, TextMeasureRequest};
11use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
12use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Paint, rect_path};
13use crate::engine::ui::components::input_text_types::{
14    InputTextEvent, InputTextKey, InputTextState,
15};
16use crate::engine::ui::custom_node::{ContentSize, CustomNode};
17
18/// Caret blink interval in milliseconds.
19const CARET_BLINK_INTERVAL_MS: u64 = 500;
20
21/// Callback invoked when the text input's value changes.
22pub type OnValueChange = dyn Fn(&str) + Send + Sync;
23
24/// Snapshot of the editing state used for undo/redo.
25#[derive(Debug, Clone, PartialEq, Eq)]
26struct EditSnapshot {
27    value: String,
28    caret: usize,
29    preedit: String,
30}
31
32/// An HTML text input rendered by the engine.
33pub struct InputTextComponent {
34    state: Mutex<InputTextState>,
35    placeholder: SmolStr,
36    measurer: Arc<dyn text::TextMeasurer>,
37    undo_stack: Mutex<Vec<EditSnapshot>>,
38    redo_stack: Mutex<Vec<EditSnapshot>>,
39    dirty: AtomicBool,
40    blink_visible: AtomicBool,
41    blink_last_toggle: Mutex<Option<Instant>>,
42    on_value_change: Option<Arc<OnValueChange>>,
43    on_enter: Option<Arc<OnValueChange>>,
44    on_copy: Option<Arc<OnValueChange>>,
45    on_cut: Option<Arc<OnValueChange>>,
46}
47
48impl std::fmt::Debug for InputTextComponent {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("InputTextComponent")
51            .field("state", &self.state.lock().unwrap())
52            .field("placeholder", &self.placeholder)
53            .finish_non_exhaustive()
54    }
55}
56
57impl InputTextComponent {
58    /// Creates a text input with an initial value and placeholder.
59    pub fn new(
60        value: impl Into<String>,
61        placeholder: impl Into<SmolStr>,
62        measurer: Arc<dyn text::TextMeasurer>,
63    ) -> Self {
64        let value = value.into();
65        let caret = value.len();
66        Self {
67            state: Mutex::new(InputTextState {
68                value,
69                preedit: String::new(),
70                caret,
71                focused: false,
72                selection_start: None,
73                selection_end: None,
74            }),
75            placeholder: placeholder.into(),
76            measurer,
77            undo_stack: Mutex::new(Vec::new()),
78            redo_stack: Mutex::new(Vec::new()),
79            dirty: AtomicBool::new(true),
80            blink_visible: AtomicBool::new(true),
81            blink_last_toggle: Mutex::new(None),
82            on_value_change: None,
83            on_enter: None,
84            on_copy: None,
85            on_cut: None,
86        }
87    }
88
89    /// Creates a text input with a value change callback for DOM sync.
90    pub fn with_on_change(
91        value: impl Into<String>,
92        placeholder: impl Into<SmolStr>,
93        measurer: Arc<dyn text::TextMeasurer>,
94        on_value_change: Arc<OnValueChange>,
95    ) -> Self {
96        let mut input = Self::new(value, placeholder, measurer);
97        input.on_value_change = Some(on_value_change);
98        input
99    }
100
101    /// Creates a text input that navigates when the user presses Enter.
102    pub fn with_on_enter(
103        value: impl Into<String>,
104        placeholder: impl Into<SmolStr>,
105        measurer: Arc<dyn text::TextMeasurer>,
106        on_enter: Arc<OnValueChange>,
107    ) -> Self {
108        let mut input = Self::new(value, placeholder, measurer);
109        input.on_enter = Some(on_enter);
110        input
111    }
112
113    /// Replaces the current value and moves the caret to the end.
114    ///
115    /// Used to sync the URL bar with the active tab after navigation.
116    pub fn set_value(&self, value: impl Into<String>) {
117        let value = value.into();
118        let mut state = self.state.lock().unwrap();
119        state.value = value;
120        state.caret = state.value.len();
121        state.preedit.clear();
122        state.selection_start = None;
123        state.selection_end = None;
124        self.dirty.store(true, Ordering::Relaxed);
125    }
126
127    /// Returns a copy of the current editing state.
128    pub fn state(&self) -> InputTextState {
129        self.state.lock().unwrap().clone()
130    }
131
132    fn previous_boundary(value: &str, caret: usize) -> usize {
133        value[..caret]
134            .char_indices()
135            .next_back()
136            .map_or(0, |(index, _)| index)
137    }
138
139    fn next_boundary(value: &str, caret: usize) -> usize {
140        value[caret..]
141            .char_indices()
142            .nth(1)
143            .map_or(value.len(), |(offset, _)| caret + offset)
144    }
145
146    /// Clamp `caret` to a valid UTF-8 character boundary within `[0, value.len()]`.
147    ///
148    /// If `caret` is beyond the string length it is clamped to `value.len()`.
149    /// If `caret` falls inside a multi-byte character it is moved to the
150    /// preceding character boundary. Returns `0` for an empty string.
151    fn clamp_caret(value: &str, caret: usize) -> usize {
152        let len = value.len();
153        if caret >= len {
154            return len;
155        }
156        let mut last_boundary = 0;
157        for (i, _) in value.char_indices() {
158            if i > caret {
159                break;
160            }
161            last_boundary = i;
162        }
163        last_boundary
164    }
165
166    fn handle_key(state: &mut InputTextState, key: InputTextKey) {
167        // If a selection is active, Backspace/Delete removes the selected range
168        // instead of a single character.
169        if let (Some(start), Some(end)) = (state.selection_start, state.selection_end) {
170            match key {
171                InputTextKey::Backspace | InputTextKey::Delete => {
172                    let start = Self::clamp_caret(&state.value, start);
173                    let end = Self::clamp_caret(&state.value, end);
174                    if end > start {
175                        state.value.replace_range(start..end, "");
176                    }
177                    state.selection_start = None;
178                    state.selection_end = None;
179                    state.caret = Self::clamp_caret(&state.value, start);
180                    return;
181                }
182                _ => {}
183            }
184        }
185
186        match key {
187            InputTextKey::Backspace if state.caret > 0 => {
188                let previous = Self::previous_boundary(&state.value, state.caret);
189                state.value.replace_range(previous..state.caret, "");
190                state.caret = Self::clamp_caret(&state.value, previous);
191            }
192            InputTextKey::Delete if state.caret < state.value.len() => {
193                let next = Self::next_boundary(&state.value, state.caret);
194                state.value.replace_range(state.caret..next, "");
195                state.caret = Self::clamp_caret(&state.value, state.caret);
196            }
197            InputTextKey::Left => {
198                state.caret = Self::clamp_caret(
199                    &state.value,
200                    Self::previous_boundary(&state.value, state.caret),
201                );
202            }
203            InputTextKey::Right => {
204                state.caret =
205                    Self::clamp_caret(&state.value, Self::next_boundary(&state.value, state.caret));
206            }
207            InputTextKey::Home => state.caret = 0,
208            InputTextKey::End => state.caret = state.value.len(),
209            InputTextKey::Backspace | InputTextKey::Delete => {}
210        }
211        state.caret = Self::clamp_caret(&state.value, state.caret);
212    }
213
214    fn snapshot(&self) -> EditSnapshot {
215        let state = self.state.lock().unwrap();
216        EditSnapshot {
217            value: state.value.clone(),
218            caret: state.caret,
219            preedit: state.preedit.clone(),
220        }
221    }
222
223    fn push_undo(&self, snapshot: EditSnapshot) {
224        self.undo_stack.lock().unwrap().push(snapshot);
225        self.redo_stack.lock().unwrap().clear();
226    }
227
228    fn undo(&self) {
229        let Some(snapshot) = self.undo_stack.lock().unwrap().pop() else {
230            return;
231        };
232        self.redo_stack.lock().unwrap().push(self.snapshot());
233        let mut state = self.state.lock().unwrap();
234        state.value = snapshot.value;
235        state.caret = Self::clamp_caret(&state.value, snapshot.caret);
236        state.preedit = snapshot.preedit;
237        self.dirty.store(true, Ordering::Relaxed);
238    }
239
240    fn redo(&self) {
241        let Some(snapshot) = self.redo_stack.lock().unwrap().pop() else {
242            return;
243        };
244        self.undo_stack.lock().unwrap().push(self.snapshot());
245        let mut state = self.state.lock().unwrap();
246        state.value = snapshot.value;
247        state.caret = Self::clamp_caret(&state.value, snapshot.caret);
248        state.preedit = snapshot.preedit;
249        self.dirty.store(true, Ordering::Relaxed);
250    }
251}
252
253impl CustomNode for InputTextComponent {
254    fn draw_sized(
255        &self,
256        cmd_buf: &mut Vec<DrawCommand>,
257        text_style: &TextStyle,
258        text_flow_style: &TextFlowStyle,
259        _style: &Style,
260        size: ContentSize,
261    ) {
262        let state = self.state.lock().unwrap();
263        let mut style = text_style.clone();
264        let (display_text, placeholder) = if state.value.is_empty() && state.preedit.is_empty() {
265            (self.placeholder.as_str().to_owned(), true)
266        } else {
267            (
268                format!(
269                    "{}{}{}",
270                    &state.value[..state.caret],
271                    state.preedit,
272                    &state.value[state.caret..]
273                ),
274                false,
275            )
276        };
277        if placeholder {
278            style.color = Color(128, 128, 128, 255);
279        }
280
281        let preedit =
282            (!state.preedit.is_empty()).then_some((state.caret, state.caret + state.preedit.len()));
283        // Caret blink animation: toggle visibility every CARET_BLINK_INTERVAL_MS
284        // while focused. When unfocused the caret is hidden.
285        let caret = if state.focused {
286            let now = Instant::now();
287            let mut last_toggle = self.blink_last_toggle.lock().unwrap();
288            let should_toggle = match *last_toggle {
289                Some(last)
290                    if now.duration_since(last)
291                        >= Duration::from_millis(CARET_BLINK_INTERVAL_MS) =>
292                {
293                    *last_toggle = Some(now);
294                    true
295                }
296                None => {
297                    *last_toggle = Some(now);
298                    true
299                }
300                _ => false,
301            };
302            if should_toggle {
303                self.blink_visible.store(
304                    !self.blink_visible.load(Ordering::Relaxed),
305                    Ordering::Relaxed,
306                );
307            }
308            if self.blink_visible.load(Ordering::Relaxed) {
309                Some(state.caret + state.preedit.len())
310            } else {
311                None
312            }
313        } else {
314            None
315        };
316
317        draw_text_input(
318            &*self.measurer,
319            cmd_buf,
320            display_text,
321            0.0,
322            ((size.height - text_flow_style.font_size) * 0.5).max(0.0),
323            &style,
324            text_flow_style,
325            caret,
326            preedit,
327            text_style.color,
328            0.0,
329            size.height.max(0.0),
330            (size.height - 1.0).max(0.0),
331        );
332    }
333
334    fn intrinsic_size(&self) -> ContentSize {
335        ContentSize {
336            width: 200.0,
337            height: 28.0,
338        }
339    }
340
341    fn accepts_text_input(&self) -> bool {
342        true
343    }
344
345    fn set_focused(&self, focused: bool) {
346        let mut state = self.state.lock().unwrap();
347        state.focused = focused;
348        self.dirty.store(true, Ordering::Relaxed);
349        if !focused {
350            state.preedit.clear();
351        }
352    }
353
354    fn is_focused(&self) -> bool {
355        self.state.lock().unwrap().focused
356    }
357
358    fn handle_text_input(&self, event: InputTextEvent) -> bool {
359        match event {
360            InputTextEvent::Insert(text)
361            | InputTextEvent::Commit(text)
362            | InputTextEvent::Paste(text) => {
363                let text: String = text
364                    .chars()
365                    .filter(|character| !character.is_control())
366                    .collect();
367                if text.is_empty() {
368                    return true;
369                }
370                self.push_undo(self.snapshot());
371                let mut state = self.state.lock().unwrap();
372                // If a selection is active, replace it with the inserted text.
373                if let (Some(start), Some(end)) = (state.selection_start, state.selection_end) {
374                    let start = Self::clamp_caret(&state.value, start);
375                    let end = Self::clamp_caret(&state.value, end);
376                    if end > start {
377                        state.value.replace_range(start..end, "");
378                        state.selection_start = None;
379                        state.selection_end = None;
380                        state.caret = Self::clamp_caret(&state.value, start);
381                    }
382                }
383                let caret = Self::clamp_caret(&state.value, state.caret);
384                state.value.insert_str(caret, &text);
385                state.caret = Self::clamp_caret(&state.value, caret + text.len());
386                state.preedit.clear();
387                let value = state.value.clone();
388                drop(state);
389                self.dirty.store(true, Ordering::Relaxed);
390                if let Some(ref cb) = self.on_value_change {
391                    cb(&value);
392                }
393            }
394            InputTextEvent::Preedit(text) => {
395                let mut state = self.state.lock().unwrap();
396                if state.preedit != text {
397                    state.preedit = text;
398                    self.dirty.store(true, Ordering::Relaxed);
399                }
400            }
401            InputTextEvent::Key(key) => {
402                let changed = matches!(key, InputTextKey::Backspace | InputTextKey::Delete);
403                if changed {
404                    self.push_undo(self.snapshot());
405                }
406                let mut state = self.state.lock().unwrap();
407                state.preedit.clear();
408                Self::handle_key(&mut state, key);
409                let value = state.value.clone();
410                drop(state);
411                self.dirty.store(true, Ordering::Relaxed);
412                if changed && let Some(ref cb) = self.on_value_change {
413                    cb(&value);
414                }
415            }
416            InputTextEvent::Enter => {
417                let mut state = self.state.lock().unwrap();
418                if !state.preedit.is_empty() {
419                    state.preedit.clear();
420                    self.dirty.store(true, Ordering::Relaxed);
421                }
422                let value = state.value.clone();
423                drop(state);
424                if let Some(ref cb) = self.on_enter {
425                    cb(&value);
426                }
427            }
428            InputTextEvent::Undo => {
429                self.undo();
430            }
431            InputTextEvent::Redo => {
432                self.redo();
433            }
434            InputTextEvent::CancelComposition => {
435                let mut state = self.state.lock().unwrap();
436                if !state.preedit.is_empty() {
437                    state.preedit.clear();
438                    self.dirty.store(true, Ordering::Relaxed);
439                }
440            }
441            InputTextEvent::Select { start, end } => {
442                let mut state = self.state.lock().unwrap();
443                let len = state.value.len();
444                let start = start.map(|s| Self::clamp_caret(&state.value, s));
445                let end = end.map(|e| Self::clamp_caret(&state.value, e));
446                match (start, end) {
447                    (Some(s), Some(e)) if s <= e => {
448                        state.selection_start = Some(s);
449                        state.selection_end = Some(e);
450                        state.caret = Self::clamp_caret(&state.value, e);
451                    }
452                    (Some(s), None) => {
453                        state.selection_start = Some(s);
454                        state.selection_end = Some(len);
455                        state.caret = Self::clamp_caret(&state.value, len);
456                    }
457                    (None, Some(e)) => {
458                        state.selection_start = Some(0);
459                        state.selection_end = Some(e);
460                        state.caret = Self::clamp_caret(&state.value, e);
461                    }
462                    _ => {
463                        // Both None or invalid: clear selection.
464                        state.selection_start = None;
465                        state.selection_end = None;
466                    }
467                }
468                self.dirty.store(true, Ordering::Relaxed);
469            }
470            InputTextEvent::Copy => {
471                let state = self.state.lock().unwrap();
472                if let (Some(start), Some(end)) = (state.selection_start, state.selection_end) {
473                    let start = Self::clamp_caret(&state.value, start);
474                    let end = Self::clamp_caret(&state.value, end);
475                    if end > start
476                        && let Some(ref cb) = self.on_copy
477                    {
478                        cb(&state.value[start..end]);
479                    }
480                }
481            }
482            InputTextEvent::Cut => {
483                self.push_undo(self.snapshot());
484                let mut state = self.state.lock().unwrap();
485                if let (Some(start), Some(end)) = (state.selection_start, state.selection_end) {
486                    let start = Self::clamp_caret(&state.value, start);
487                    let end = Self::clamp_caret(&state.value, end);
488                    if end > start {
489                        let selected = state.value[start..end].to_string();
490                        state.value.replace_range(start..end, "");
491                        state.selection_start = None;
492                        state.selection_end = None;
493                        state.caret = Self::clamp_caret(&state.value, start);
494                        self.dirty.store(true, Ordering::Relaxed);
495                        if let Some(ref cb) = self.on_copy {
496                            cb(&selected);
497                        }
498                        if let Some(ref cb) = self.on_cut {
499                            cb(&selected);
500                        }
501                        return true;
502                    }
503                }
504                state.selection_start = None;
505                state.selection_end = None;
506                self.dirty.store(true, Ordering::Relaxed);
507            }
508            InputTextEvent::SelectAll => {
509                let mut state = self.state.lock().unwrap();
510                let len = state.value.len();
511                if len > 0 {
512                    state.selection_start = Some(0);
513                    state.selection_end = Some(len);
514                    state.caret = len;
515                } else {
516                    state.selection_start = None;
517                    state.selection_end = None;
518                    state.caret = 0;
519                }
520                self.dirty.store(true, Ordering::Relaxed);
521            }
522        }
523        true
524    }
525
526    fn is_composing(&self) -> bool {
527        !self.state.lock().unwrap().preedit.is_empty()
528    }
529
530    fn needs_repaint(&self) -> bool {
531        // When focused, always request repaint so the caret blink animation
532        // continues. When unfocused, only repaint on explicit dirtiness.
533        if self.is_focused() {
534            return true;
535        }
536        self.dirty.swap(false, Ordering::Relaxed)
537    }
538
539    fn composition_rect(&self) -> Option<(f32, f32, f32, f32)> {
540        let state = self.state.lock().unwrap();
541        if state.preedit.is_empty() {
542            return None;
543        }
544        let display_text = format!(
545            "{}{}{}",
546            &state.value[..state.caret],
547            state.preedit,
548            &state.value[state.caret..]
549        );
550        let style = TextStyle::default();
551        let Ok(fragments) = self.measurer.measure(&TextMeasureRequest {
552            text: display_text,
553            attribute: text::TextAttribute {
554                style,
555                flow_style: TextFlowStyle::default(),
556            },
557        }) else {
558            return None;
559        };
560        let mut byte_offset = 0;
561        let mut width = 0.0;
562        let mut start_x = None;
563        let mut preedit_width = 0.0;
564        let preedit_start = state.caret;
565        let preedit_end = state.caret + state.preedit.len();
566        for fragment in &fragments {
567            let frag_start = byte_offset;
568            let frag_end = byte_offset + fragment.text.len();
569            if start_x.is_none() && frag_start >= preedit_start {
570                start_x = Some(width);
571            }
572            if frag_end <= preedit_end && frag_start < preedit_end {
573                preedit_width += fragment.width;
574            }
575            byte_offset = frag_end;
576            width += fragment.width;
577        }
578        let start_x = start_x.unwrap_or(width);
579        Some((start_x, 0.0, preedit_width.max(0.0), 28.0_f32.max(0.0)))
580    }
581
582    fn role(&self) -> Option<&'static str> {
583        Some("textbox")
584    }
585
586    fn label(&self) -> Option<String> {
587        (!self.placeholder.is_empty()).then(|| self.placeholder.to_string())
588    }
589
590    fn value(&self) -> Option<String> {
591        Some(self.state.lock().unwrap().value.clone())
592    }
593}
594
595/// Draw text input decorations such as caret and IME preedit underline.
596#[allow(clippy::too_many_arguments)]
597fn draw_text_input(
598    measurer: &dyn text::TextMeasurer,
599    cmd_buf: &mut Vec<DrawCommand>,
600    text: String,
601    x: f32,
602    y: f32,
603    style: &TextStyle,
604    flow_style: &TextFlowStyle,
605    caret: Option<usize>,
606    preedit: Option<(usize, usize)>,
607    decoration_color: Color,
608    caret_top: f32,
609    caret_height: f32,
610    underline_y: f32,
611) {
612    let Ok(fragments) = measurer.measure(&TextMeasureRequest {
613        text: text.clone(),
614        attribute: text::TextAttribute {
615            style: style.clone(),
616            flow_style: *flow_style,
617        },
618    }) else {
619        return;
620    };
621
622    let mut caret_x = None;
623    let mut preedit_start_x = None;
624    let mut preedit_end_x = None;
625
626    let mut byte_offset = 0;
627    let mut width = 0.0;
628
629    for fragment in &fragments {
630        if let Some(caret_pos) = caret
631            && caret_x.is_none()
632            && caret_pos <= byte_offset
633        {
634            caret_x = Some(width);
635        }
636
637        if let Some((start, end)) = preedit {
638            if preedit_start_x.is_none() && start <= byte_offset {
639                preedit_start_x = Some(width);
640            }
641
642            if preedit_end_x.is_none() && end <= byte_offset {
643                preedit_end_x = Some(width);
644            }
645        }
646
647        byte_offset += fragment.text.len();
648        width += fragment.width;
649    }
650
651    if let Some(caret_pos) = caret
652        && caret_x.is_none()
653        && caret_pos <= byte_offset
654    {
655        caret_x = Some(width);
656    }
657
658    if let Some((start, end)) = preedit {
659        if preedit_start_x.is_none() && start <= byte_offset {
660            preedit_start_x = Some(width);
661        }
662
663        if preedit_end_x.is_none() && end <= byte_offset {
664            preedit_end_x = Some(width);
665        }
666    }
667
668    let paint = Paint {
669        brush: Brush::Solid(decoration_color),
670        opacity: 1.0,
671    };
672
673    if let (Some(start_x), Some(end_x)) = (preedit_start_x, preedit_end_x) {
674        cmd_buf.push(DrawCommand::Fill {
675            path: rect_path(x + start_x, underline_y, (end_x - start_x).max(0.0), 1.0),
676            rule: FillRule::NonZero,
677            paint: paint.clone(),
678        });
679    }
680
681    if let Some(caret_x) = caret_x {
682        cmd_buf.push(DrawCommand::Fill {
683            path: rect_path(x + caret_x, caret_top, 1.0, caret_height),
684            rule: FillRule::NonZero,
685            paint,
686        });
687    }
688
689    cmd_buf.push(DrawCommand::DrawText {
690        x,
691        y,
692        text: text.into(),
693        style: style.clone(),
694        flow_style: *flow_style,
695    });
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    use crate::engine::bridge::text::FallbackTextMeasurer;
703
704    fn make_component(value: &str, placeholder: &str) -> InputTextComponent {
705        InputTextComponent::new(value, placeholder, Arc::new(FallbackTextMeasurer))
706    }
707
708    #[test]
709    fn preedit_is_replaced_by_ime_commit() {
710        let input = make_component("abc", "");
711        input.handle_text_input(InputTextEvent::Preedit("にほ".into()));
712        assert_eq!(input.state().preedit, "にほ");
713
714        input.handle_text_input(InputTextEvent::Commit("日本".into()));
715        let state = input.state();
716        assert_eq!(state.value, "abc日本");
717        assert!(state.preedit.is_empty());
718    }
719
720    #[test]
721    fn editing_uses_utf8_character_boundaries() {
722        let input = make_component("a日b", "");
723        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
724        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
725        assert_eq!(input.state().value, "ab");
726    }
727
728    #[test]
729    fn undo_redo_restores_value_and_caret() {
730        let input = make_component("", "");
731        input.handle_text_input(InputTextEvent::Insert("abc".into()));
732        assert_eq!(input.state().value, "abc");
733
734        input.handle_text_input(InputTextEvent::Undo);
735        assert_eq!(input.state().value, "");
736
737        input.handle_text_input(InputTextEvent::Redo);
738        assert_eq!(input.state().value, "abc");
739    }
740
741    #[test]
742    fn paste_inserts_at_caret() {
743        let input = make_component("ab", "");
744        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
745        input.handle_text_input(InputTextEvent::Paste("XY".into()));
746        assert_eq!(input.state().value, "aXYb");
747    }
748
749    #[test]
750    fn enter_keeps_value_and_clears_preedit() {
751        let input = make_component("abc", "");
752        input.handle_text_input(InputTextEvent::Preedit("にほ".into()));
753        input.handle_text_input(InputTextEvent::Enter);
754        let state = input.state();
755        assert_eq!(state.value, "abc");
756        assert!(state.preedit.is_empty());
757    }
758
759    #[test]
760    fn role_and_value_for_accessibility() {
761        let input = make_component("hello", "Name");
762        assert_eq!(input.role(), Some("textbox"));
763        assert_eq!(input.label(), Some("Name".to_string()));
764        assert_eq!(input.value(), Some("hello".to_string()));
765    }
766
767    #[test]
768    fn on_value_change_callback_fires() {
769        use std::sync::{Arc, Mutex};
770        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
771        let received_clone = Arc::clone(&received);
772        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
773            received_clone.lock().unwrap().push(v.to_string());
774        });
775        let input = InputTextComponent::with_on_change("", "", Arc::new(FallbackTextMeasurer), cb);
776
777        input.handle_text_input(InputTextEvent::Insert("hello".into()));
778        assert_eq!(*received.lock().unwrap(), vec!["hello"]);
779
780        input.handle_text_input(InputTextEvent::Insert(" world".into()));
781        assert_eq!(*received.lock().unwrap(), vec!["hello", "hello world"]);
782    }
783
784    #[test]
785    fn on_enter_callback_fires_with_current_value() {
786        use std::sync::{Arc, Mutex};
787        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
788        let received_clone = Arc::clone(&received);
789        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
790            received_clone.lock().unwrap().push(v.to_string());
791        });
792        let input = InputTextComponent::with_on_enter("", "", Arc::new(FallbackTextMeasurer), cb);
793
794        input.handle_text_input(InputTextEvent::Insert("https://example.com".into()));
795        input.handle_text_input(InputTextEvent::Enter);
796        assert_eq!(*received.lock().unwrap(), vec!["https://example.com"]);
797    }
798
799    #[test]
800    fn set_value_updates_value_and_caret() {
801        let input = make_component("old", "");
802        input.set_value("new value");
803        let state = input.state();
804        assert_eq!(state.value, "new value");
805        assert_eq!(state.caret, "new value".len());
806        assert!(input.needs_repaint());
807    }
808
809    #[test]
810    fn caret_clamped_after_backspace_at_start() {
811        let input = make_component("abc", "");
812        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
813        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
814        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
815        let state = input.state();
816        assert_eq!(state.value, "");
817        assert_eq!(state.caret, 0);
818    }
819
820    #[test]
821    fn caret_clamped_after_delete_at_end() {
822        let input = make_component("abc", "");
823        // Delete at end does nothing (nothing after caret)
824        input.handle_text_input(InputTextEvent::Key(InputTextKey::Delete));
825        let state = input.state();
826        assert_eq!(state.value, "abc");
827        assert_eq!(state.caret, 3);
828
829        // Move left, then delete removes characters
830        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
831        input.handle_text_input(InputTextEvent::Key(InputTextKey::Delete));
832        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
833        input.handle_text_input(InputTextEvent::Key(InputTextKey::Delete));
834        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
835        input.handle_text_input(InputTextEvent::Key(InputTextKey::Delete));
836        let state = input.state();
837        assert_eq!(state.value, "");
838        assert_eq!(state.caret, 0);
839    }
840
841    #[test]
842    fn caret_clamped_after_set_value_shortens_text() {
843        let input = make_component("hello world", "");
844        input.set_value("hi");
845        let state = input.state();
846        assert_eq!(state.value, "hi");
847        assert_eq!(state.caret, 2);
848    }
849
850    #[test]
851    fn caret_clamped_after_undo_from_stale_snapshot() {
852        let input = make_component("abc", "");
853        input.handle_text_input(InputTextEvent::Insert("日".into()));
854        assert_eq!(input.state().value, "abc日");
855        assert_eq!(input.state().caret, 6);
856
857        // Undo should restore "abc" with caret clamped to 3
858        input.handle_text_input(InputTextEvent::Undo);
859        let state = input.state();
860        assert_eq!(state.value, "abc");
861        assert_eq!(state.caret, 3);
862    }
863
864    #[test]
865    fn caret_clamped_after_redo_from_stale_snapshot() {
866        let input = make_component("abc", "");
867        input.handle_text_input(InputTextEvent::Insert("日".into()));
868        input.handle_text_input(InputTextEvent::Undo);
869        let state = input.state();
870        assert_eq!(state.value, "abc");
871        assert_eq!(state.caret, 3);
872
873        // Redo should restore "abc日" with caret at 6
874        input.handle_text_input(InputTextEvent::Redo);
875        let state = input.state();
876        assert_eq!(state.value, "abc日");
877        assert_eq!(state.caret, 6);
878    }
879
880    #[test]
881    fn caret_stays_at_utf8_boundary_with_multibyte_chars() {
882        let input = make_component("a日b", "");
883        // Move caret left from end (6) to before 'b' (4)
884        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
885        assert_eq!(input.state().caret, 4);
886        // Move left again to before '日' (1)
887        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
888        assert_eq!(input.state().caret, 1);
889        // Move left again to before 'a' (0)
890        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
891        assert_eq!(input.state().caret, 0);
892        // Move right to before '日' (1)
893        input.handle_text_input(InputTextEvent::Key(InputTextKey::Right));
894        assert_eq!(input.state().caret, 1);
895        // Move right to before 'b' (4)
896        input.handle_text_input(InputTextEvent::Key(InputTextKey::Right));
897        assert_eq!(input.state().caret, 4);
898        // Move right to end (5)
899        input.handle_text_input(InputTextEvent::Key(InputTextKey::Right));
900        assert_eq!(input.state().caret, 5);
901    }
902
903    #[test]
904    fn select_all_selects_entire_text() {
905        let input = make_component("abc", "");
906        input.handle_text_input(InputTextEvent::SelectAll);
907        let state = input.state();
908        assert_eq!(state.selection_start, Some(0));
909        assert_eq!(state.selection_end, Some(3));
910        assert_eq!(state.caret, 3);
911    }
912
913    #[test]
914    fn select_all_on_empty_clears_selection() {
915        let input = make_component("", "");
916        input.handle_text_input(InputTextEvent::SelectAll);
917        let state = input.state();
918        assert_eq!(state.selection_start, None);
919        assert_eq!(state.selection_end, None);
920        assert_eq!(state.caret, 0);
921    }
922
923    #[test]
924    fn select_none_clears_selection() {
925        let input = make_component("abc", "");
926        input.handle_text_input(InputTextEvent::SelectAll);
927        assert!(input.state().selection_start.is_some());
928        input.handle_text_input(InputTextEvent::Select {
929            start: None,
930            end: None,
931        });
932        let state = input.state();
933        assert_eq!(state.selection_start, None);
934        assert_eq!(state.selection_end, None);
935    }
936
937    #[test]
938    fn typing_with_selection_replaces_selection() {
939        let input = make_component("abc", "");
940        input.handle_text_input(InputTextEvent::SelectAll);
941        input.handle_text_input(InputTextEvent::Insert("X".into()));
942        let state = input.state();
943        assert_eq!(state.value, "X");
944        assert_eq!(state.caret, 1);
945        assert_eq!(state.selection_start, None);
946        assert_eq!(state.selection_end, None);
947    }
948
949    #[test]
950    fn backspace_with_selection_deletes_selection() {
951        let input = make_component("abc", "");
952        input.handle_text_input(InputTextEvent::SelectAll);
953        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
954        let state = input.state();
955        assert_eq!(state.value, "");
956        assert_eq!(state.caret, 0);
957        assert_eq!(state.selection_start, None);
958        assert_eq!(state.selection_end, None);
959    }
960
961    #[test]
962    fn delete_with_selection_deletes_selection() {
963        let input = make_component("abc", "");
964        input.handle_text_input(InputTextEvent::SelectAll);
965        input.handle_text_input(InputTextEvent::Key(InputTextKey::Delete));
966        let state = input.state();
967        assert_eq!(state.value, "");
968        assert_eq!(state.caret, 0);
969        assert_eq!(state.selection_start, None);
970        assert_eq!(state.selection_end, None);
971    }
972
973    #[test]
974    fn copy_fires_callback_with_selected_text() {
975        use std::sync::{Arc, Mutex};
976        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
977        let received_clone = Arc::clone(&received);
978        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
979            received_clone.lock().unwrap().push(v.to_string());
980        });
981        let mut input = make_component("abc", "");
982        input.on_copy = Some(cb);
983        input.handle_text_input(InputTextEvent::SelectAll);
984        input.handle_text_input(InputTextEvent::Copy);
985        assert_eq!(*received.lock().unwrap(), vec!["abc"]);
986    }
987
988    #[test]
989    fn cut_fires_callbacks_and_removes_selection() {
990        use std::sync::{Arc, Mutex};
991        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
992        let received_clone = Arc::clone(&received);
993        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
994            received_clone.lock().unwrap().push(v.to_string());
995        });
996        let mut input = make_component("abc", "");
997        input.on_copy = Some(Arc::clone(&cb));
998        input.on_cut = Some(cb);
999        input.handle_text_input(InputTextEvent::SelectAll);
1000        input.handle_text_input(InputTextEvent::Cut);
1001        assert_eq!(*received.lock().unwrap(), vec!["abc", "abc"]);
1002        let state = input.state();
1003        assert_eq!(state.value, "");
1004        assert_eq!(state.caret, 0);
1005        assert_eq!(state.selection_start, None);
1006        assert_eq!(state.selection_end, None);
1007    }
1008
1009    #[test]
1010    fn set_value_clears_selection() {
1011        let input = make_component("abc", "");
1012        input.handle_text_input(InputTextEvent::SelectAll);
1013        assert!(input.state().selection_start.is_some());
1014        input.set_value("xyz");
1015        let state = input.state();
1016        assert_eq!(state.value, "xyz");
1017        assert_eq!(state.caret, 3);
1018        assert_eq!(state.selection_start, None);
1019        assert_eq!(state.selection_end, None);
1020    }
1021
1022    #[test]
1023    fn partial_selection_insert_replaces_range() {
1024        let input = make_component("hello", "");
1025        input.handle_text_input(InputTextEvent::Select {
1026            start: Some(1),
1027            end: Some(4),
1028        });
1029        input.handle_text_input(InputTextEvent::Insert("X".into()));
1030        let state = input.state();
1031        assert_eq!(state.value, "hXo");
1032        assert_eq!(state.caret, 2);
1033        assert_eq!(state.selection_start, None);
1034        assert_eq!(state.selection_end, None);
1035    }
1036}