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};
5
6use smol_str::SmolStr;
7use ui_layout::Style;
8
9use crate::engine::bridge::text::{self, TextMeasureRequest};
10use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
11use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Paint, rect_path};
12use crate::engine::ui::components::input_text_types::{
13    InputTextEvent, InputTextKey, InputTextState,
14};
15use crate::engine::ui::custom_node::{ContentSize, CustomNode};
16
17/// Callback invoked when the text input's value changes.
18pub type OnValueChange = dyn Fn(&str) + Send + Sync;
19
20const INLINE_PADDING: f32 = 4.0;
21
22/// Snapshot of the editing state used for undo/redo.
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct EditSnapshot {
25    value: String,
26    caret: usize,
27    preedit: String,
28}
29
30/// An HTML text input rendered by the engine.
31pub struct InputTextComponent {
32    state: Mutex<InputTextState>,
33    placeholder: SmolStr,
34    measurer: Arc<dyn text::TextMeasurer>,
35    undo_stack: Mutex<Vec<EditSnapshot>>,
36    redo_stack: Mutex<Vec<EditSnapshot>>,
37    dirty: AtomicBool,
38    on_value_change: Option<Arc<OnValueChange>>,
39    on_enter: Option<Arc<OnValueChange>>,
40}
41
42impl std::fmt::Debug for InputTextComponent {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("InputTextComponent")
45            .field("state", &self.state.lock().unwrap())
46            .field("placeholder", &self.placeholder)
47            .finish_non_exhaustive()
48    }
49}
50
51impl InputTextComponent {
52    /// Creates a text input with an initial value and placeholder.
53    pub fn new(
54        value: impl Into<String>,
55        placeholder: impl Into<SmolStr>,
56        measurer: Arc<dyn text::TextMeasurer>,
57    ) -> Self {
58        let value = value.into();
59        let caret = value.len();
60        Self {
61            state: Mutex::new(InputTextState {
62                value,
63                preedit: String::new(),
64                caret,
65                focused: false,
66            }),
67            placeholder: placeholder.into(),
68            measurer,
69            undo_stack: Mutex::new(Vec::new()),
70            redo_stack: Mutex::new(Vec::new()),
71            dirty: AtomicBool::new(true),
72            on_value_change: None,
73            on_enter: None,
74        }
75    }
76
77    /// Creates a text input with a value change callback for DOM sync.
78    pub fn with_on_change(
79        value: impl Into<String>,
80        placeholder: impl Into<SmolStr>,
81        measurer: Arc<dyn text::TextMeasurer>,
82        on_value_change: Arc<OnValueChange>,
83    ) -> Self {
84        let mut input = Self::new(value, placeholder, measurer);
85        input.on_value_change = Some(on_value_change);
86        input
87    }
88
89    /// Creates a text input that navigates when the user presses Enter.
90    pub fn with_on_enter(
91        value: impl Into<String>,
92        placeholder: impl Into<SmolStr>,
93        measurer: Arc<dyn text::TextMeasurer>,
94        on_enter: Arc<OnValueChange>,
95    ) -> Self {
96        let mut input = Self::new(value, placeholder, measurer);
97        input.on_enter = Some(on_enter);
98        input
99    }
100
101    /// Replaces the current value and moves the caret to the end.
102    ///
103    /// Used to sync the URL bar with the active tab after navigation.
104    pub fn set_value(&self, value: impl Into<String>) {
105        let value = value.into();
106        let mut state = self.state.lock().unwrap();
107        state.value = value;
108        state.caret = state.value.len();
109        state.preedit.clear();
110        self.dirty.store(true, Ordering::Relaxed);
111    }
112
113    /// Returns a copy of the current editing state.
114    pub fn state(&self) -> InputTextState {
115        self.state.lock().unwrap().clone()
116    }
117
118    fn previous_boundary(value: &str, caret: usize) -> usize {
119        value[..caret]
120            .char_indices()
121            .next_back()
122            .map_or(0, |(index, _)| index)
123    }
124
125    fn next_boundary(value: &str, caret: usize) -> usize {
126        value[caret..]
127            .char_indices()
128            .nth(1)
129            .map_or(value.len(), |(offset, _)| caret + offset)
130    }
131
132    fn handle_key(state: &mut InputTextState, key: InputTextKey) {
133        match key {
134            InputTextKey::Backspace if state.caret > 0 => {
135                let previous = Self::previous_boundary(&state.value, state.caret);
136                state.value.replace_range(previous..state.caret, "");
137                state.caret = previous;
138            }
139            InputTextKey::Delete if state.caret < state.value.len() => {
140                let next = Self::next_boundary(&state.value, state.caret);
141                state.value.replace_range(state.caret..next, "");
142            }
143            InputTextKey::Left => {
144                state.caret = Self::previous_boundary(&state.value, state.caret);
145            }
146            InputTextKey::Right => {
147                state.caret = Self::next_boundary(&state.value, state.caret);
148            }
149            InputTextKey::Home => state.caret = 0,
150            InputTextKey::End => state.caret = state.value.len(),
151            InputTextKey::Backspace | InputTextKey::Delete => {}
152        }
153    }
154
155    fn snapshot(&self) -> EditSnapshot {
156        let state = self.state.lock().unwrap();
157        EditSnapshot {
158            value: state.value.clone(),
159            caret: state.caret,
160            preedit: state.preedit.clone(),
161        }
162    }
163
164    fn push_undo(&self, snapshot: EditSnapshot) {
165        self.undo_stack.lock().unwrap().push(snapshot);
166        self.redo_stack.lock().unwrap().clear();
167    }
168
169    fn undo(&self) {
170        let Some(snapshot) = self.undo_stack.lock().unwrap().pop() else {
171            return;
172        };
173        self.redo_stack.lock().unwrap().push(self.snapshot());
174        let mut state = self.state.lock().unwrap();
175        state.value = snapshot.value;
176        state.caret = snapshot.caret;
177        state.preedit = snapshot.preedit;
178        self.dirty.store(true, Ordering::Relaxed);
179    }
180
181    fn redo(&self) {
182        let Some(snapshot) = self.redo_stack.lock().unwrap().pop() else {
183            return;
184        };
185        self.undo_stack.lock().unwrap().push(self.snapshot());
186        let mut state = self.state.lock().unwrap();
187        state.value = snapshot.value;
188        state.caret = snapshot.caret;
189        state.preedit = snapshot.preedit;
190        self.dirty.store(true, Ordering::Relaxed);
191    }
192}
193
194impl CustomNode for InputTextComponent {
195    fn draw_sized(
196        &self,
197        cmd_buf: &mut Vec<DrawCommand>,
198        text_style: &TextStyle,
199        text_flow_style: &TextFlowStyle,
200        _style: &Style,
201        size: ContentSize,
202    ) {
203        cmd_buf.push(DrawCommand::Fill {
204            path: rect_path(0.0, 0.0, size.width, size.height),
205            rule: FillRule::NonZero,
206            paint: Paint {
207                brush: Brush::Solid(Color(255, 255, 255, 255)),
208                opacity: 1.0,
209            },
210        });
211
212        let state = self.state.lock().unwrap();
213        let mut style = text_style.clone();
214        let (display_text, placeholder) = if state.value.is_empty() && state.preedit.is_empty() {
215            (self.placeholder.as_str().to_owned(), true)
216        } else {
217            (
218                format!(
219                    "{}{}{}",
220                    &state.value[..state.caret],
221                    state.preedit,
222                    &state.value[state.caret..]
223                ),
224                false,
225            )
226        };
227        if placeholder {
228            style.color = Color(128, 128, 128, 255);
229        }
230
231        let preedit =
232            (!state.preedit.is_empty()).then_some((state.caret, state.caret + state.preedit.len()));
233        let caret = state.focused.then_some(state.caret + state.preedit.len());
234
235        draw_text_input(
236            &*self.measurer,
237            cmd_buf,
238            display_text,
239            INLINE_PADDING,
240            ((size.height - text_flow_style.font_size) * 0.5).max(0.0),
241            &style,
242            text_flow_style,
243            caret,
244            preedit,
245            text_style.color,
246            INLINE_PADDING,
247            (size.height - INLINE_PADDING * 2.0).max(0.0),
248            (size.height - 3.0).max(0.0),
249        );
250    }
251
252    fn intrinsic_size(&self) -> ContentSize {
253        ContentSize {
254            width: 200.0,
255            height: 28.0,
256        }
257    }
258
259    fn accepts_text_input(&self) -> bool {
260        true
261    }
262
263    fn set_focused(&self, focused: bool) {
264        let mut state = self.state.lock().unwrap();
265        state.focused = focused;
266        self.dirty.store(true, Ordering::Relaxed);
267        if !focused {
268            state.preedit.clear();
269        }
270    }
271
272    fn is_focused(&self) -> bool {
273        self.state.lock().unwrap().focused
274    }
275
276    fn handle_text_input(&self, event: InputTextEvent) -> bool {
277        match event {
278            InputTextEvent::Insert(text)
279            | InputTextEvent::Commit(text)
280            | InputTextEvent::Paste(text) => {
281                let text: String = text
282                    .chars()
283                    .filter(|character| !character.is_control())
284                    .collect();
285                if text.is_empty() {
286                    return true;
287                }
288                self.push_undo(self.snapshot());
289                let mut state = self.state.lock().unwrap();
290                let caret = state.caret;
291                state.value.insert_str(caret, &text);
292                state.caret += text.len();
293                state.preedit.clear();
294                let value = state.value.clone();
295                drop(state);
296                self.dirty.store(true, Ordering::Relaxed);
297                if let Some(ref cb) = self.on_value_change {
298                    cb(&value);
299                }
300            }
301            InputTextEvent::Preedit(text) => {
302                let mut state = self.state.lock().unwrap();
303                if state.preedit != text {
304                    state.preedit = text;
305                    self.dirty.store(true, Ordering::Relaxed);
306                }
307            }
308            InputTextEvent::Key(key) => {
309                let changed = matches!(key, InputTextKey::Backspace | InputTextKey::Delete);
310                if changed {
311                    self.push_undo(self.snapshot());
312                }
313                let mut state = self.state.lock().unwrap();
314                state.preedit.clear();
315                Self::handle_key(&mut state, key);
316                let value = state.value.clone();
317                drop(state);
318                self.dirty.store(true, Ordering::Relaxed);
319                if changed && let Some(ref cb) = self.on_value_change {
320                    cb(&value);
321                }
322            }
323            InputTextEvent::Enter => {
324                let mut state = self.state.lock().unwrap();
325                if !state.preedit.is_empty() {
326                    state.preedit.clear();
327                    self.dirty.store(true, Ordering::Relaxed);
328                }
329                let value = state.value.clone();
330                drop(state);
331                if let Some(ref cb) = self.on_enter {
332                    cb(&value);
333                }
334            }
335            InputTextEvent::Undo => {
336                self.undo();
337            }
338            InputTextEvent::Redo => {
339                self.redo();
340            }
341            InputTextEvent::CancelComposition => {
342                let mut state = self.state.lock().unwrap();
343                if !state.preedit.is_empty() {
344                    state.preedit.clear();
345                    self.dirty.store(true, Ordering::Relaxed);
346                }
347            }
348        }
349        true
350    }
351
352    fn is_composing(&self) -> bool {
353        !self.state.lock().unwrap().preedit.is_empty()
354    }
355
356    fn needs_repaint(&self) -> bool {
357        self.dirty.swap(false, Ordering::Relaxed)
358    }
359
360    fn composition_rect(&self) -> Option<(f32, f32, f32, f32)> {
361        let state = self.state.lock().unwrap();
362        if state.preedit.is_empty() {
363            return None;
364        }
365        let display_text = format!(
366            "{}{}{}",
367            &state.value[..state.caret],
368            state.preedit,
369            &state.value[state.caret..]
370        );
371        let style = TextStyle::default();
372        let Ok(fragments) = self.measurer.measure(&TextMeasureRequest {
373            text: display_text,
374            attribute: text::TextAttribute {
375                style,
376                flow_style: TextFlowStyle::default(),
377            },
378        }) else {
379            return None;
380        };
381        let mut byte_offset = 0;
382        let mut width = 0.0;
383        let mut start_x = None;
384        let mut preedit_width = 0.0;
385        let preedit_start = state.caret;
386        let preedit_end = state.caret + state.preedit.len();
387        for fragment in &fragments {
388            let frag_start = byte_offset;
389            let frag_end = byte_offset + fragment.text.len();
390            if start_x.is_none() && frag_start >= preedit_start {
391                start_x = Some(width);
392            }
393            if frag_end <= preedit_end && frag_start < preedit_end {
394                preedit_width += fragment.width;
395            }
396            byte_offset = frag_end;
397            width += fragment.width;
398        }
399        let start_x = start_x.unwrap_or(width);
400        Some((start_x, 0.0, preedit_width.max(0.0), 1.0))
401    }
402
403    fn role(&self) -> Option<&'static str> {
404        Some("textbox")
405    }
406
407    fn label(&self) -> Option<String> {
408        (!self.placeholder.is_empty()).then(|| self.placeholder.to_string())
409    }
410
411    fn value(&self) -> Option<String> {
412        Some(self.state.lock().unwrap().value.clone())
413    }
414}
415
416/// Draw text input decorations such as caret and IME preedit underline.
417#[allow(clippy::too_many_arguments)]
418fn draw_text_input(
419    measurer: &dyn text::TextMeasurer,
420    cmd_buf: &mut Vec<DrawCommand>,
421    text: String,
422    x: f32,
423    y: f32,
424    style: &TextStyle,
425    flow_style: &TextFlowStyle,
426    caret: Option<usize>,
427    preedit: Option<(usize, usize)>,
428    decoration_color: Color,
429    caret_top: f32,
430    caret_height: f32,
431    underline_y: f32,
432) {
433    let Ok(fragments) = measurer.measure(&TextMeasureRequest {
434        text: text.clone(),
435        attribute: text::TextAttribute {
436            style: style.clone(),
437            flow_style: *flow_style,
438        },
439    }) else {
440        return;
441    };
442
443    let mut caret_x = None;
444    let mut preedit_start_x = None;
445    let mut preedit_end_x = None;
446
447    let mut byte_offset = 0;
448    let mut width = 0.0;
449
450    for fragment in &fragments {
451        if let Some(caret_pos) = caret
452            && caret_x.is_none()
453            && caret_pos <= byte_offset
454        {
455            caret_x = Some(width);
456        }
457
458        if let Some((start, end)) = preedit {
459            if preedit_start_x.is_none() && start <= byte_offset {
460                preedit_start_x = Some(width);
461            }
462
463            if preedit_end_x.is_none() && end <= byte_offset {
464                preedit_end_x = Some(width);
465            }
466        }
467
468        byte_offset += fragment.text.len();
469        width += fragment.width;
470    }
471
472    if let Some(caret_pos) = caret
473        && caret_x.is_none()
474        && caret_pos <= byte_offset
475    {
476        caret_x = Some(width);
477    }
478
479    if let Some((start, end)) = preedit {
480        if preedit_start_x.is_none() && start <= byte_offset {
481            preedit_start_x = Some(width);
482        }
483
484        if preedit_end_x.is_none() && end <= byte_offset {
485            preedit_end_x = Some(width);
486        }
487    }
488
489    let paint = Paint {
490        brush: Brush::Solid(decoration_color),
491        opacity: 1.0,
492    };
493
494    if let (Some(start_x), Some(end_x)) = (preedit_start_x, preedit_end_x) {
495        cmd_buf.push(DrawCommand::Fill {
496            path: rect_path(x + start_x, underline_y, (end_x - start_x).max(0.0), 1.0),
497            rule: FillRule::NonZero,
498            paint: paint.clone(),
499        });
500    }
501
502    if let Some(caret_x) = caret_x {
503        cmd_buf.push(DrawCommand::Fill {
504            path: rect_path(x + caret_x, caret_top, 1.0, caret_height),
505            rule: FillRule::NonZero,
506            paint,
507        });
508    }
509
510    cmd_buf.push(DrawCommand::DrawText {
511        x,
512        y,
513        text: text.into(),
514        style: style.clone(),
515        flow_style: *flow_style,
516    });
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    use crate::engine::bridge::text::FallbackTextMeasurer;
524
525    fn make_component(value: &str, placeholder: &str) -> InputTextComponent {
526        InputTextComponent::new(value, placeholder, Arc::new(FallbackTextMeasurer))
527    }
528
529    #[test]
530    fn preedit_is_replaced_by_ime_commit() {
531        let input = make_component("abc", "");
532        input.handle_text_input(InputTextEvent::Preedit("にほ".into()));
533        assert_eq!(input.state().preedit, "にほ");
534
535        input.handle_text_input(InputTextEvent::Commit("日本".into()));
536        let state = input.state();
537        assert_eq!(state.value, "abc日本");
538        assert!(state.preedit.is_empty());
539    }
540
541    #[test]
542    fn editing_uses_utf8_character_boundaries() {
543        let input = make_component("a日b", "");
544        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
545        input.handle_text_input(InputTextEvent::Key(InputTextKey::Backspace));
546        assert_eq!(input.state().value, "ab");
547    }
548
549    #[test]
550    fn undo_redo_restores_value_and_caret() {
551        let input = make_component("", "");
552        input.handle_text_input(InputTextEvent::Insert("abc".into()));
553        assert_eq!(input.state().value, "abc");
554
555        input.handle_text_input(InputTextEvent::Undo);
556        assert_eq!(input.state().value, "");
557
558        input.handle_text_input(InputTextEvent::Redo);
559        assert_eq!(input.state().value, "abc");
560    }
561
562    #[test]
563    fn paste_inserts_at_caret() {
564        let input = make_component("ab", "");
565        input.handle_text_input(InputTextEvent::Key(InputTextKey::Left));
566        input.handle_text_input(InputTextEvent::Paste("XY".into()));
567        assert_eq!(input.state().value, "aXYb");
568    }
569
570    #[test]
571    fn enter_keeps_value_and_clears_preedit() {
572        let input = make_component("abc", "");
573        input.handle_text_input(InputTextEvent::Preedit("にほ".into()));
574        input.handle_text_input(InputTextEvent::Enter);
575        let state = input.state();
576        assert_eq!(state.value, "abc");
577        assert!(state.preedit.is_empty());
578    }
579
580    #[test]
581    fn role_and_value_for_accessibility() {
582        let input = make_component("hello", "Name");
583        assert_eq!(input.role(), Some("textbox"));
584        assert_eq!(input.label(), Some("Name".to_string()));
585        assert_eq!(input.value(), Some("hello".to_string()));
586    }
587
588    #[test]
589    fn on_value_change_callback_fires() {
590        use std::sync::{Arc, Mutex};
591        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
592        let received_clone = Arc::clone(&received);
593        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
594            received_clone.lock().unwrap().push(v.to_string());
595        });
596        let input = InputTextComponent::with_on_change("", "", Arc::new(FallbackTextMeasurer), cb);
597
598        input.handle_text_input(InputTextEvent::Insert("hello".into()));
599        assert_eq!(*received.lock().unwrap(), vec!["hello"]);
600
601        input.handle_text_input(InputTextEvent::Insert(" world".into()));
602        assert_eq!(*received.lock().unwrap(), vec!["hello", "hello world"]);
603    }
604
605    #[test]
606    fn on_enter_callback_fires_with_current_value() {
607        use std::sync::{Arc, Mutex};
608        let received: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
609        let received_clone = Arc::clone(&received);
610        let cb: Arc<OnValueChange> = Arc::new(move |v: &str| {
611            received_clone.lock().unwrap().push(v.to_string());
612        });
613        let input = InputTextComponent::with_on_enter("", "", Arc::new(FallbackTextMeasurer), cb);
614
615        input.handle_text_input(InputTextEvent::Insert("https://example.com".into()));
616        input.handle_text_input(InputTextEvent::Enter);
617        assert_eq!(*received.lock().unwrap(), vec!["https://example.com"]);
618    }
619
620    #[test]
621    fn set_value_updates_value_and_caret() {
622        let input = make_component("old", "");
623        input.set_value("new value");
624        let state = input.state();
625        assert_eq!(state.value, "new value");
626        assert_eq!(state.caret, "new value".len());
627        assert!(input.needs_repaint());
628    }
629}