Skip to main content

gpui_rhai/
text_input.rs

1//! Native text input behavior adapted from GPUI's Apache-2.0 `examples/input.rs`.
2
3use std::collections::BTreeMap;
4use std::ops::Range;
5use std::rc::Rc;
6
7use gpui::{
8    App, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, Element, ElementId,
9    ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, FontFallbacks,
10    FontWeight, GlobalElementId, IntoElement, KeyBinding, KeyDownEvent, LayoutId, MouseButton,
11    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render, ShapedLine,
12    SharedString, Style as GpuiStyle, TextAlign, TextRun, UTF16Selection, UnderlineStyle, Window,
13    actions, div, fill, point, prelude::*, px, relative, rgba, size,
14};
15
16pub use crate::text_edit::TextBuffer;
17
18use crate::{
19    ComponentStateSchema, EventSchema, ObjectField, PrimitiveContext, PrimitiveDescriptor,
20    PrimitiveHandler, PrimitiveId, PrimitiveInstance, PrimitiveInstanceId, Rgba8, UiValue,
21    ValueSchema,
22};
23
24actions!(
25    gpui_rhai_input,
26    [
27        Backspace,
28        Delete,
29        Left,
30        Right,
31        SelectLeft,
32        SelectRight,
33        SelectAll,
34        Home,
35        End,
36        Submit,
37        ShowCharacterPalette,
38        Paste,
39        Cut,
40        Copy,
41        Undo,
42        Redo,
43    ]
44);
45
46pub fn init_text_input(cx: &mut App) {
47    cx.bind_keys([
48        KeyBinding::new("backspace", Backspace, Some("GPUIRhaiTextInput")),
49        KeyBinding::new("delete", Delete, Some("GPUIRhaiTextInput")),
50        KeyBinding::new("left", Left, Some("GPUIRhaiTextInput")),
51        KeyBinding::new("right", Right, Some("GPUIRhaiTextInput")),
52        KeyBinding::new("shift-left", SelectLeft, Some("GPUIRhaiTextInput")),
53        KeyBinding::new("shift-right", SelectRight, Some("GPUIRhaiTextInput")),
54        KeyBinding::new("cmd-a", SelectAll, Some("GPUIRhaiTextInput")),
55        KeyBinding::new("cmd-v", Paste, Some("GPUIRhaiTextInput")),
56        KeyBinding::new("cmd-c", Copy, Some("GPUIRhaiTextInput")),
57        KeyBinding::new("cmd-x", Cut, Some("GPUIRhaiTextInput")),
58        KeyBinding::new("cmd-z", Undo, Some("GPUIRhaiTextInput")),
59        KeyBinding::new("shift-cmd-z", Redo, Some("GPUIRhaiTextInput")),
60        KeyBinding::new("home", Home, Some("GPUIRhaiTextInput")),
61        KeyBinding::new("end", End, Some("GPUIRhaiTextInput")),
62        KeyBinding::new("enter", Submit, Some("GPUIRhaiTextInput")),
63        KeyBinding::new(
64            "ctrl-cmd-space",
65            ShowCharacterPalette,
66            Some("GPUIRhaiTextInput"),
67        ),
68    ]);
69}
70
71pub(crate) type TextValueHandler = Rc<dyn Fn(String, &mut Window, &mut App)>;
72pub(crate) type TextSignalHandler = Rc<dyn Fn(&mut Window, &mut App)>;
73pub(crate) type TextTabHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
74
75#[derive(Clone, Default)]
76pub(crate) struct TextInputCallbacks {
77    pub change: Option<TextValueHandler>,
78    pub submit: Option<TextValueHandler>,
79    pub focus: Option<TextSignalHandler>,
80    pub blur: Option<TextSignalHandler>,
81    pub tab: Option<TextTabHandler>,
82}
83
84pub(crate) struct TextInputEntity {
85    focus: FocusHandle,
86    buffer: TextBuffer,
87    placeholder: SharedString,
88    disabled: bool,
89    read_only: bool,
90    selection_color: Rgba8,
91    caret_color: Rgba8,
92    typography: NativeTypography,
93    callbacks: TextInputCallbacks,
94    last_layout: Option<ShapedLine>,
95    last_bounds: Option<Bounds<Pixels>>,
96    scroll_x: Pixels,
97    selecting: bool,
98}
99
100#[derive(Clone)]
101pub(crate) struct TextInputConfig {
102    pub(crate) value: String,
103    pub(crate) placeholder: String,
104    pub(crate) disabled: bool,
105    pub(crate) read_only: bool,
106    pub(crate) autofocus: bool,
107    pub(crate) selection_color: Rgba8,
108    pub(crate) caret_color: Rgba8,
109    pub(crate) typography: NativeTypography,
110}
111
112#[derive(Clone, Debug)]
113pub(crate) struct NativeTypography {
114    pub(crate) family: Option<String>,
115    pub(crate) fallbacks: Vec<String>,
116    pub(crate) font_size: Pixels,
117    pub(crate) line_height: Pixels,
118    pub(crate) weight: u16,
119}
120
121pub(crate) fn native_typography(
122    theme: &crate::PrimitiveTheme,
123    role: &str,
124    window: &Window,
125) -> Result<NativeTypography, String> {
126    let typography = theme
127        .typography(role)
128        .ok_or_else(|| format!("native text primitive cannot resolve typography role `{role}`"))?;
129    Ok(NativeTypography {
130        family: typography.family,
131        fallbacks: typography.fallbacks,
132        font_size: native_length(typography.size, window.rem_size(), role, "size")?,
133        line_height: native_length(
134            typography.line_height,
135            window.rem_size(),
136            role,
137            "line_height",
138        )?,
139        weight: typography.weight,
140    })
141}
142
143fn native_length(
144    value: crate::Length,
145    rem_size: Pixels,
146    role: &str,
147    field: &str,
148) -> Result<Pixels, String> {
149    match value {
150        crate::Length::Pixels(value) => value
151            .to_string()
152            .parse()
153            .map(px)
154            .map_err(|_| format!("typography `{role}.{field}` cannot fit native f32 pixels")),
155        crate::Length::Rems(value) => value
156            .to_string()
157            .parse::<f32>()
158            .map(|value| rem_size * value)
159            .map_err(|_| format!("typography `{role}.{field}` cannot fit native f32 rems")),
160        crate::Length::Relative(_)
161        | crate::Length::ThemeSpacing(_)
162        | crate::Length::ThemeRadius(_) => Err(format!(
163            "typography `{role}.{field}` must resolve to pixels or rems"
164        )),
165    }
166}
167
168impl TextInputEntity {
169    pub(crate) fn callbacks(&self) -> TextInputCallbacks {
170        self.callbacks.clone()
171    }
172
173    pub(crate) fn new(
174        config: TextInputConfig,
175        callbacks: TextInputCallbacks,
176        cx: &mut Context<Self>,
177    ) -> Self {
178        Self {
179            focus: cx.focus_handle().tab_stop(!config.disabled),
180            buffer: TextBuffer::new(&config.value),
181            placeholder: config.placeholder.into(),
182            disabled: config.disabled,
183            read_only: config.read_only,
184            selection_color: config.selection_color,
185            caret_color: config.caret_color,
186            typography: config.typography,
187            callbacks,
188            last_layout: None,
189            last_bounds: None,
190            scroll_x: px(0.0),
191            selecting: false,
192        }
193    }
194
195    pub(crate) fn update_props(
196        &mut self,
197        config: &TextInputConfig,
198        callbacks: TextInputCallbacks,
199        cx: &mut Context<Self>,
200    ) {
201        self.buffer.set_controlled(&config.value);
202        self.focus = self.focus.clone().tab_stop(!config.disabled);
203        self.placeholder = config.placeholder.clone().into();
204        self.disabled = config.disabled;
205        self.read_only = config.read_only;
206        self.selection_color = config.selection_color;
207        self.caret_color = config.caret_color;
208        self.typography = config.typography.clone();
209        self.callbacks = callbacks;
210        cx.notify();
211    }
212
213    fn emit_change(&self, window: &mut Window, cx: &mut Context<Self>) {
214        if let Some(change) = self.callbacks.change.clone() {
215            let value = self.buffer.content.clone();
216            window.defer(cx, move |window, cx| change(value, window, cx));
217        }
218    }
219
220    fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
221        let offset = if self.buffer.selected.is_empty() {
222            self.buffer.previous_boundary(self.buffer.cursor_offset())
223        } else {
224            self.buffer.selected.start
225        };
226        self.buffer.move_to(offset);
227        cx.notify();
228    }
229
230    fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
231        let offset = if self.buffer.selected.is_empty() {
232            self.buffer.next_boundary(self.buffer.selected.end)
233        } else {
234            self.buffer.selected.end
235        };
236        self.buffer.move_to(offset);
237        cx.notify();
238    }
239
240    fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
241        let offset = self.buffer.previous_boundary(self.buffer.cursor_offset());
242        self.buffer.select_to(offset);
243        cx.notify();
244    }
245
246    fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
247        let offset = self.buffer.next_boundary(self.buffer.cursor_offset());
248        self.buffer.select_to(offset);
249        cx.notify();
250    }
251
252    fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
253        self.buffer.move_to(0);
254        self.buffer.select_to(self.buffer.content.len());
255        cx.notify();
256    }
257
258    fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
259        self.buffer.move_to(0);
260        cx.notify();
261    }
262
263    fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
264        self.buffer.move_to(self.buffer.content.len());
265        cx.notify();
266    }
267
268    fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
269        if self.disabled || self.read_only {
270            return;
271        }
272        if self.buffer.selected.is_empty() {
273            let previous = self.buffer.previous_boundary(self.buffer.cursor_offset());
274            self.buffer.select_to(previous);
275        }
276        self.buffer.replace(None, "");
277        self.emit_change(window, cx);
278        cx.notify();
279    }
280
281    fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
282        if self.disabled || self.read_only {
283            return;
284        }
285        if self.buffer.selected.is_empty() {
286            let next = self.buffer.next_boundary(self.buffer.cursor_offset());
287            self.buffer.select_to(next);
288        }
289        self.buffer.replace(None, "");
290        self.emit_change(window, cx);
291        cx.notify();
292    }
293
294    fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
295        if !self.buffer.selected.is_empty() {
296            cx.write_to_clipboard(ClipboardItem::new_string(
297                self.buffer.content[self.buffer.selected.clone()].to_owned(),
298            ));
299        }
300    }
301
302    fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
303        if self.disabled || self.read_only || self.buffer.selected.is_empty() {
304            return;
305        }
306        self.copy(&Copy, window, cx);
307        self.buffer.replace(None, "");
308        self.emit_change(window, cx);
309        cx.notify();
310    }
311
312    fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
313        if self.disabled || self.read_only {
314            return;
315        }
316        if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
317            self.buffer.replace(None, &text.replace('\n', " "));
318            self.emit_change(window, cx);
319            cx.notify();
320        }
321    }
322
323    fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context<Self>) {
324        if !self.disabled && !self.read_only && self.buffer.undo() {
325            self.emit_change(window, cx);
326            cx.notify();
327        }
328    }
329
330    fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context<Self>) {
331        if !self.disabled && !self.read_only && self.buffer.redo() {
332            self.emit_change(window, cx);
333            cx.notify();
334        }
335    }
336
337    fn submit(&mut self, _: &Submit, window: &mut Window, cx: &mut Context<Self>) {
338        if !self.disabled
339            && let Some(submit) = self.callbacks.submit.clone()
340        {
341            let value = self.buffer.content.clone();
342            window.defer(cx, move |window, cx| submit(value, window, cx));
343        }
344    }
345
346    fn show_character_palette(
347        &mut self,
348        _: &ShowCharacterPalette,
349        window: &mut Window,
350        _: &mut Context<Self>,
351    ) {
352        if !self.disabled {
353            window.show_character_palette();
354        }
355    }
356
357    fn index_for_mouse(&self, position: Point<Pixels>) -> usize {
358        let (Some(bounds), Some(line)) = (&self.last_bounds, &self.last_layout) else {
359            return 0;
360        };
361        if position.y < bounds.top() {
362            0
363        } else if position.y > bounds.bottom() {
364            self.buffer.content.len()
365        } else {
366            line.closest_index_for_x(position.x - bounds.left() + self.scroll_x)
367        }
368    }
369
370    fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
371        if self.disabled {
372            return;
373        }
374        self.focus.focus(window, cx);
375        self.selecting = true;
376        let index = self.index_for_mouse(event.position);
377        if event.modifiers.shift {
378            self.buffer.select_to(index);
379        } else {
380            self.buffer.move_to(index);
381        }
382        cx.notify();
383    }
384
385    fn mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
386        self.selecting = false;
387    }
388
389    fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
390        if self.selecting {
391            let index = self.index_for_mouse(event.position);
392            self.buffer.select_to(index);
393            cx.notify();
394        }
395    }
396}
397
398impl EntityInputHandler for TextInputEntity {
399    fn text_for_range(
400        &mut self,
401        range_utf16: Range<usize>,
402        actual_range: &mut Option<Range<usize>>,
403        _: &mut Window,
404        _: &mut Context<Self>,
405    ) -> Option<String> {
406        let range = self.buffer.range_from_utf16(&range_utf16);
407        actual_range.replace(self.buffer.range_to_utf16(&range));
408        self.buffer.content.get(range).map(ToOwned::to_owned)
409    }
410
411    fn selected_text_range(
412        &mut self,
413        _ignore_disabled_input: bool,
414        _: &mut Window,
415        _: &mut Context<Self>,
416    ) -> Option<UTF16Selection> {
417        Some(UTF16Selection {
418            range: self.buffer.range_to_utf16(&self.buffer.selected),
419            reversed: self.buffer.selection_reversed,
420        })
421    }
422
423    fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
424        self.buffer
425            .marked
426            .as_ref()
427            .map(|range| self.buffer.range_to_utf16(range))
428    }
429
430    fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
431        self.buffer.unmark();
432    }
433
434    fn replace_text_in_range(
435        &mut self,
436        range_utf16: Option<Range<usize>>,
437        text: &str,
438        window: &mut Window,
439        cx: &mut Context<Self>,
440    ) {
441        if self.disabled || self.read_only {
442            return;
443        }
444        self.buffer.replace(range_utf16.as_ref(), text);
445        self.emit_change(window, cx);
446        cx.notify();
447    }
448
449    fn replace_and_mark_text_in_range(
450        &mut self,
451        range_utf16: Option<Range<usize>>,
452        text: &str,
453        selected_utf16: Option<Range<usize>>,
454        window: &mut Window,
455        cx: &mut Context<Self>,
456    ) {
457        if self.disabled || self.read_only {
458            return;
459        }
460        self.buffer
461            .replace_and_mark(range_utf16.as_ref(), text, selected_utf16);
462        self.emit_change(window, cx);
463        cx.notify();
464    }
465
466    fn bounds_for_range(
467        &mut self,
468        range_utf16: Range<usize>,
469        bounds: Bounds<Pixels>,
470        _: &mut Window,
471        _: &mut Context<Self>,
472    ) -> Option<Bounds<Pixels>> {
473        let line = self.last_layout.as_ref()?;
474        let range = self.buffer.range_from_utf16(&range_utf16);
475        Some(Bounds::from_corners(
476            point(
477                bounds.left() + line.x_for_index(range.start) - self.scroll_x,
478                bounds.top(),
479            ),
480            point(
481                bounds.left() + line.x_for_index(range.end) - self.scroll_x,
482                bounds.bottom(),
483            ),
484        ))
485    }
486
487    fn character_index_for_point(
488        &mut self,
489        point: Point<Pixels>,
490        _: &mut Window,
491        _: &mut Context<Self>,
492    ) -> Option<usize> {
493        let bounds = self.last_bounds?;
494        if !bounds.contains(&point) {
495            return None;
496        }
497        let line = self.last_layout.as_ref()?;
498        let index = line.index_for_x(point.x - bounds.left() + self.scroll_x)?;
499        Some(self.buffer.offset_to_utf16(index))
500    }
501}
502
503struct TextElement {
504    input: Entity<TextInputEntity>,
505}
506
507struct TextPrepaint {
508    line: Option<ShapedLine>,
509    cursor: Option<PaintQuad>,
510    selection: Option<PaintQuad>,
511    origin: Point<Pixels>,
512    scroll_x: Pixels,
513}
514
515fn horizontal_scroll_for_cursor(
516    line_width: Pixels,
517    cursor_x: Pixels,
518    viewport_width: Pixels,
519    current: Pixels,
520) -> Pixels {
521    let viewport_width = viewport_width.max(px(0.0));
522    let max_scroll = (line_width - viewport_width).max(px(0.0));
523    let mut scroll_x = current.min(max_scroll);
524    if cursor_x < scroll_x {
525        scroll_x = cursor_x;
526    } else if cursor_x > scroll_x + viewport_width - px(2.0) {
527        scroll_x = (cursor_x - viewport_width + px(2.0)).min(max_scroll);
528    }
529    scroll_x.max(px(0.0))
530}
531
532impl IntoElement for TextElement {
533    type Element = Self;
534
535    fn into_element(self) -> Self::Element {
536        self
537    }
538}
539
540impl Element for TextElement {
541    type RequestLayoutState = ();
542    type PrepaintState = TextPrepaint;
543
544    fn id(&self) -> Option<ElementId> {
545        None
546    }
547
548    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
549        None
550    }
551
552    fn request_layout(
553        &mut self,
554        _: Option<&GlobalElementId>,
555        _: Option<&gpui::InspectorElementId>,
556        window: &mut Window,
557        cx: &mut App,
558    ) -> (LayoutId, ()) {
559        let mut style = GpuiStyle::default();
560        style.size.width = relative(1.0).into();
561        style.size.height = window.line_height().into();
562        (window.request_layout(style, [], cx), ())
563    }
564
565    fn prepaint(
566        &mut self,
567        _: Option<&GlobalElementId>,
568        _: Option<&gpui::InspectorElementId>,
569        bounds: Bounds<Pixels>,
570        _request_layout: &mut (),
571        window: &mut Window,
572        cx: &mut App,
573    ) -> TextPrepaint {
574        let input = self.input.read(cx);
575        let content: SharedString = input.buffer.content.clone().into();
576        let selected = input.buffer.selected.clone();
577        let cursor = input.buffer.cursor_offset();
578        let text_style = window.text_style();
579        let (display, color) = if content.is_empty() {
580            (input.placeholder.clone(), text_style.color.opacity(0.55))
581        } else {
582            (content, text_style.color)
583        };
584        let run = TextRun {
585            len: display.len(),
586            font: text_style.font(),
587            color,
588            background_color: None,
589            underline: None,
590            strikethrough: None,
591        };
592        let runs = if let Some(marked) = input.buffer.marked.as_ref() {
593            vec![
594                TextRun {
595                    len: marked.start,
596                    ..run.clone()
597                },
598                TextRun {
599                    len: marked.end - marked.start,
600                    underline: Some(UnderlineStyle {
601                        color: Some(run.color),
602                        thickness: px(1.0),
603                        wavy: false,
604                    }),
605                    ..run.clone()
606                },
607                TextRun {
608                    len: display.len() - marked.end,
609                    ..run
610                },
611            ]
612            .into_iter()
613            .filter(|run| run.len > 0)
614            .collect()
615        } else {
616            vec![run]
617        };
618        let font_size = text_style.font_size.to_pixels(window.rem_size());
619        let line = window
620            .text_system()
621            .shape_line(display, font_size, &runs, None);
622        let cursor_x = line.x_for_index(cursor);
623        let scroll_x =
624            horizontal_scroll_for_cursor(line.width, cursor_x, bounds.size.width, input.scroll_x);
625        let origin = point(bounds.left() - scroll_x, bounds.top());
626        let (selection, cursor) = if selected.is_empty() {
627            (
628                None,
629                Some(fill(
630                    Bounds::new(
631                        point(origin.x + cursor_x, bounds.top()),
632                        size(px(1.5), bounds.size.height),
633                    ),
634                    rgba(input.caret_color.as_rgba_hex()),
635                )),
636            )
637        } else {
638            (
639                Some(fill(
640                    Bounds::from_corners(
641                        point(origin.x + line.x_for_index(selected.start), bounds.top()),
642                        point(origin.x + line.x_for_index(selected.end), bounds.bottom()),
643                    ),
644                    rgba(input.selection_color.as_rgba_hex()),
645                )),
646                None,
647            )
648        };
649        TextPrepaint {
650            line: Some(line),
651            cursor,
652            selection,
653            origin,
654            scroll_x,
655        }
656    }
657
658    fn paint(
659        &mut self,
660        _: Option<&GlobalElementId>,
661        _: Option<&gpui::InspectorElementId>,
662        bounds: Bounds<Pixels>,
663        _request_layout: &mut (),
664        state: &mut TextPrepaint,
665        window: &mut Window,
666        cx: &mut App,
667    ) {
668        let focus = self.input.read(cx).focus.clone();
669        window.handle_input(
670            &focus,
671            ElementInputHandler::new(bounds, self.input.clone()),
672            cx,
673        );
674        let Some(line) = state.line.take() else {
675            return;
676        };
677        window.with_content_mask(Some(ContentMask { bounds }), |window| {
678            if let Some(selection) = state.selection.take() {
679                window.paint_quad(selection);
680            }
681            let _ = line.paint(
682                state.origin,
683                window.line_height(),
684                TextAlign::Left,
685                None,
686                window,
687                cx,
688            );
689            if focus.is_focused(window)
690                && let Some(cursor) = state.cursor.take()
691            {
692                window.paint_quad(cursor);
693            }
694        });
695        self.input.update(cx, |input, _| {
696            input.last_layout = Some(line);
697            input.last_bounds = Some(bounds);
698            input.scroll_x = state.scroll_x;
699        });
700    }
701}
702
703impl Render for TextInputEntity {
704    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
705        let tab = self.callbacks.tab.clone();
706        let submit_enabled = self.callbacks.submit.is_some();
707        let mut root = div().flex().size_full().items_center();
708        if let Some(family) = &self.typography.family {
709            root = root.font_family(family.clone());
710        }
711        if !self.typography.fallbacks.is_empty() {
712            root.text_style().font_fallbacks =
713                Some(FontFallbacks::from_fonts(self.typography.fallbacks.clone()));
714        }
715        root.font_weight(FontWeight(f32::from(self.typography.weight)))
716            .key_context("GPUIRhaiTextInput")
717            .track_focus(&self.focus)
718            .on_key_down(move |event: &KeyDownEvent, window, cx| {
719                if event.keystroke.key.as_str() == "tab" {
720                    if let Some(tab) = &tab {
721                        let tab = tab.clone();
722                        let shift = event.keystroke.modifiers.shift;
723                        window.defer(cx, move |window, cx| tab(shift, window, cx));
724                    } else if event.keystroke.modifiers.shift {
725                        window.focus_prev(cx);
726                    } else {
727                        window.focus_next(cx);
728                    }
729                    cx.stop_propagation();
730                }
731            })
732            .cursor(CursorStyle::IBeam)
733            .on_action(cx.listener(Self::backspace))
734            .on_action(cx.listener(Self::delete))
735            .on_action(cx.listener(Self::left))
736            .on_action(cx.listener(Self::right))
737            .on_action(cx.listener(Self::select_left))
738            .on_action(cx.listener(Self::select_right))
739            .on_action(cx.listener(Self::select_all))
740            .on_action(cx.listener(Self::home))
741            .on_action(cx.listener(Self::end))
742            .when(submit_enabled, |input| {
743                input.on_action(cx.listener(Self::submit))
744            })
745            .on_action(cx.listener(Self::show_character_palette))
746            .on_action(cx.listener(Self::paste))
747            .on_action(cx.listener(Self::cut))
748            .on_action(cx.listener(Self::copy))
749            .on_action(cx.listener(Self::undo))
750            .on_action(cx.listener(Self::redo))
751            .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
752            .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
753            .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
754            .on_mouse_move(cx.listener(Self::mouse_move))
755            .line_height(self.typography.line_height)
756            .text_size(self.typography.font_size)
757            .opacity(if self.disabled { 0.55 } else { 1.0 })
758            .child(TextElement { input: cx.entity() })
759    }
760}
761
762impl Focusable for TextInputEntity {
763    fn focus_handle(&self, _: &App) -> FocusHandle {
764        self.focus.clone()
765    }
766}
767
768#[derive(Default)]
769pub struct TextInputPrimitiveHandler {
770    instances: BTreeMap<PrimitiveInstanceId, Entity<TextInputEntity>>,
771}
772
773impl PrimitiveHandler for TextInputPrimitiveHandler {
774    fn uses_primary_focus(&self) -> bool {
775        true
776    }
777
778    fn accessibility_actions(
779        &self,
780        _instance: &PrimitiveInstanceId,
781    ) -> Vec<gpui::AccessibleAction> {
782        vec![
783            gpui::AccessibleAction::Focus,
784            gpui::AccessibleAction::SetValue,
785        ]
786    }
787
788    fn perform_accessibility_action(
789        &mut self,
790        instance: &PrimitiveInstanceId,
791        action: gpui::AccessibleAction,
792        data: Option<&gpui::accesskit::ActionData>,
793        window: &mut Window,
794        cx: &mut App,
795    ) -> Result<(), String> {
796        let entity = self
797            .instances
798            .get(instance)
799            .cloned()
800            .ok_or_else(|| "text input accessibility target is stale".to_owned())?;
801        match action {
802            gpui::AccessibleAction::Focus => {
803                let (disabled, focus) = {
804                    let input = entity.read(cx);
805                    (input.disabled, input.focus.clone())
806                };
807                if disabled {
808                    return Err("disabled text input cannot receive focus".to_owned());
809                }
810                focus.focus(window, cx);
811                Ok(())
812            }
813            gpui::AccessibleAction::SetValue => {
814                let Some(gpui::accesskit::ActionData::Value(value)) = data else {
815                    return Err("text input SetValue requires string data".to_owned());
816                };
817                let callback = {
818                    let input = entity.read(cx);
819                    if input.disabled || input.read_only {
820                        return Err("text input is disabled or read-only".to_owned());
821                    }
822                    input.callbacks.change.clone()
823                };
824                if let Some(callback) = callback {
825                    callback(value.to_string(), window, cx);
826                }
827                Ok(())
828            }
829            _ => Err("unsupported text input accessibility action".to_owned()),
830        }
831    }
832
833    fn render(
834        &mut self,
835        instance: &PrimitiveInstance,
836        events: &PrimitiveContext,
837        theme: &crate::PrimitiveTheme,
838        window: &mut Window,
839        cx: &mut App,
840    ) -> Result<gpui::AnyElement, String> {
841        let id = instance
842            .id
843            .clone()
844            .ok_or_else(|| "TextInputPrimitive requires a stable key".to_owned())?;
845        let value = instance
846            .node
847            .props
848            .string("value")
849            .unwrap_or_default()
850            .to_owned();
851        let placeholder = instance
852            .node
853            .props
854            .string("placeholder")
855            .unwrap_or_default()
856            .to_owned();
857        let disabled = instance.node.props.boolean("disabled").unwrap_or(false);
858        let read_only = instance.node.props.boolean("read_only").unwrap_or(false);
859        let autofocus = instance.node.props.boolean("autofocus").unwrap_or(false);
860        let typography_role = instance
861            .node
862            .props
863            .string("typography")
864            .unwrap_or("body")
865            .to_owned();
866        let selection_color = theme
867            .color("selection")
868            .unwrap_or_else(|| Rgba8::from_rgba_hex(0x292e_42ff));
869        let caret_color = theme
870            .color("accent")
871            .unwrap_or_else(|| Rgba8::from_rgba_hex(0x7aa2_f7ff));
872        let config = TextInputConfig {
873            value,
874            placeholder,
875            disabled,
876            read_only,
877            autofocus,
878            selection_color,
879            caret_color,
880            typography: native_typography(theme, &typography_role, window)?,
881        };
882        let callbacks = primitive_callbacks(events);
883        let shared_focus = instance.focus_handle().cloned();
884        let entity = if let Some(entity) = self.instances.get(&id) {
885            entity.clone()
886        } else {
887            let entity = cx.new(|cx| {
888                let mut input = TextInputEntity::new(config.clone(), callbacks.clone(), cx);
889                if let Some(focus) = shared_focus.clone() {
890                    input.focus = focus.tab_stop(!config.disabled);
891                }
892                input
893            });
894            entity.update(cx, |input, cx| {
895                let focus = input.focus.clone();
896                cx.on_focus(&focus, window, |input, window, cx| {
897                    if let Some(focus) = input.callbacks.focus.clone() {
898                        window.defer(cx, move |window, cx| focus(window, cx));
899                    }
900                })
901                .detach();
902                cx.on_blur(&focus, window, |input, window, cx| {
903                    if let Some(blur) = input.callbacks.blur.clone() {
904                        window.defer(cx, move |window, cx| blur(window, cx));
905                    }
906                })
907                .detach();
908                // Matches TextArea: focus once on first mount when requested.
909                if config.autofocus && !config.disabled {
910                    input.focus.focus(window, cx);
911                }
912            });
913            self.instances.insert(id.clone(), entity.clone());
914            entity
915        };
916        entity.update(cx, |input, cx| {
917            input.update_props(&config, callbacks, cx);
918        });
919        Ok(entity.into_any_element())
920    }
921
922    fn unmount(&mut self, instance: &PrimitiveInstanceId) {
923        self.instances.remove(instance);
924    }
925}
926
927fn primitive_callbacks(events: &PrimitiveContext) -> TextInputCallbacks {
928    let change_events = events.clone();
929    let submit_events = events.clone();
930    let focus_events = events.clone();
931    let blur_events = events.clone();
932    TextInputCallbacks {
933        change: Some(Rc::new(move |value, window, cx| {
934            let _ = change_events.emit("change", UiValue::String(value), window, cx);
935        })),
936        submit: Some(Rc::new(move |value, window, cx| {
937            let _ = submit_events.emit("submit", UiValue::String(value), window, cx);
938        })),
939        focus: Some(Rc::new(move |window, cx| {
940            let _ = focus_events.emit("focus", UiValue::Null, window, cx);
941        })),
942        blur: Some(Rc::new(move |window, cx| {
943            let _ = blur_events.emit("blur", UiValue::Null, window, cx);
944        })),
945        tab: None,
946    }
947}
948
949/// Build the compile-time `TextInput` primitive schema.
950///
951/// # Panics
952///
953/// Panics only if the static built-in primitive ID becomes invalid.
954#[must_use]
955pub fn text_input_primitive_descriptor() -> PrimitiveDescriptor {
956    let optional_callback = || ObjectField::optional(ValueSchema::optional(ValueSchema::Callback));
957    PrimitiveDescriptor {
958        id: PrimitiveId::parse("gpui_rhai.text_input").expect("static primitive ID"),
959        export: "TextInputPrimitive".to_owned(),
960        props: BTreeMap::from([
961            (
962                "value".to_owned(),
963                ObjectField::required(ValueSchema::string()),
964            ),
965            (
966                "placeholder".to_owned(),
967                ObjectField::optional(ValueSchema::optional(ValueSchema::string())),
968            ),
969            (
970                "disabled".to_owned(),
971                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
972            ),
973            (
974                "read_only".to_owned(),
975                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
976            ),
977            (
978                "autofocus".to_owned(),
979                ObjectField::optional(ValueSchema::Bool).with_default(UiValue::Bool(false)),
980            ),
981            (
982                "typography".to_owned(),
983                ObjectField::required(ValueSchema::String {
984                    allowed: crate::REQUIRED_TYPOGRAPHY
985                        .iter()
986                        .map(ToString::to_string)
987                        .collect(),
988                }),
989            ),
990            ("on_change".to_owned(), optional_callback()),
991            ("on_submit".to_owned(), optional_callback()),
992            ("on_focus".to_owned(), optional_callback()),
993            ("on_blur".to_owned(), optional_callback()),
994        ]),
995        events: BTreeMap::from([
996            (
997                "change".to_owned(),
998                EventSchema {
999                    payload: ValueSchema::string(),
1000                },
1001            ),
1002            (
1003                "submit".to_owned(),
1004                EventSchema {
1005                    payload: ValueSchema::string(),
1006                },
1007            ),
1008            (
1009                "focus".to_owned(),
1010                EventSchema {
1011                    payload: ValueSchema::Null,
1012                },
1013            ),
1014            (
1015                "blur".to_owned(),
1016                EventSchema {
1017                    payload: ValueSchema::Null,
1018                },
1019            ),
1020        ]),
1021        state: ComponentStateSchema::default(),
1022        lifecycle: true,
1023        effect: None,
1024    }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030
1031    #[test]
1032    fn grapheme_navigation_does_not_split_emoji_or_combining_text() {
1033        let buffer = TextBuffer::new("aπŸ‘©β€πŸ’»e\u{301}");
1034        let end = buffer.content().len();
1035        let before_combining = buffer.previous_boundary(end);
1036        let before_emoji = buffer.previous_boundary(before_combining);
1037        assert_eq!(&buffer.content()[before_combining..], "e\u{301}");
1038        assert_eq!(&buffer.content()[before_emoji..before_combining], "πŸ‘©β€πŸ’»");
1039    }
1040
1041    #[test]
1042    fn utf16_round_trip_supports_cjk_and_surrogate_pairs() {
1043        let buffer = TextBuffer::new("δΈ­πŸ˜€ζ–‡");
1044        for offset in [0, 3, 7, buffer.content().len()] {
1045            let utf16 = buffer.offset_to_utf16(offset);
1046            assert_eq!(buffer.offset_from_utf16(utf16), offset);
1047        }
1048    }
1049
1050    #[test]
1051    fn horizontal_scroll_keeps_the_active_cursor_inside_the_viewport() {
1052        assert_eq!(
1053            horizontal_scroll_for_cursor(px(200.0), px(150.0), px(100.0), px(0.0)),
1054            px(52.0)
1055        );
1056        assert_eq!(
1057            horizontal_scroll_for_cursor(px(200.0), px(10.0), px(100.0), px(50.0)),
1058            px(10.0)
1059        );
1060        assert_eq!(
1061            horizontal_scroll_for_cursor(px(80.0), px(80.0), px(100.0), px(30.0)),
1062            px(0.0)
1063        );
1064    }
1065
1066    #[test]
1067    fn marked_text_replacement_tracks_ime_range() {
1068        let mut buffer = TextBuffer::new("");
1069        buffer.replace_and_mark(None, "に", Some(1..1));
1070        assert_eq!(buffer.content(), "に");
1071        assert_eq!(buffer.marked(), Some(&(0..3)));
1072        buffer.replace(None, "ζ—₯本");
1073        assert_eq!(buffer.content(), "ζ—₯本");
1074        assert!(buffer.marked().is_none());
1075    }
1076}