Skip to main content

ui/components/
text_input.rs

1use std::ops::Range;
2use std::rc::Rc;
3
4use gpui::{
5    AnyElement, Bounds, Context, ElementInputHandler, EntityInputHandler, FocusHandle, Focusable,
6    KeyDownEvent, MouseButton, Pixels, Render, UTF16Selection, canvas,
7};
8
9use crate::prelude::*;
10
11/// Visual validation state of a [`TextInput`], reflected in its border/focus
12/// ring color.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum InputValidationState {
15    #[default]
16    Neutral,
17    Error,
18    Success,
19    Warning,
20}
21
22/// A focusable single-line (or multi-line) text field backed by a real
23/// `String` buffer. Keyboard input is handled via key events (`key_char` +
24/// editing keys), so typed characters genuinely appear and backspace deletes.
25/// IME composition (e.g. Vietnamese/CJK input methods) is handled via the
26/// [`EntityInputHandler`] impl, so composed text commits correctly.
27///
28/// This is a stateful view: create with `cx.new(|cx| TextInput::new(cx))` and
29/// store the resulting `Entity<TextInput>`.
30pub struct TextInput {
31    content: String,
32    placeholder: SharedString,
33    focus_handle: FocusHandle,
34    multiline: bool,
35    submit_on_enter: bool,
36    validation: InputValidationState,
37    read_only: bool,
38    /// Byte range of in-progress IME marked (composition) text within
39    /// `content`, if any. `None` when no composition is active.
40    marked_range: Option<Range<usize>>,
41    on_submit: Option<Rc<dyn Fn(&mut Window, &mut Context<Self>) + 'static>>,
42}
43
44impl TextInput {
45    pub fn new(cx: &mut App) -> Self {
46        Self {
47            content: String::new(),
48            placeholder: SharedString::default(),
49            focus_handle: cx.focus_handle(),
50            multiline: false,
51            submit_on_enter: false,
52            validation: InputValidationState::Neutral,
53            read_only: false,
54            marked_range: None,
55            on_submit: None,
56        }
57    }
58
59    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
60        self.placeholder = placeholder.into();
61        self
62    }
63
64    pub fn multiline(mut self, multiline: bool) -> Self {
65        self.multiline = multiline;
66        self
67    }
68
69    /// When `true`, plain Enter fires [`Self::on_submit`] instead of
70    /// inserting a newline; Shift/Ctrl/Cmd+Enter still inserts a newline (in
71    /// `multiline` mode). Defaults to `false`, which preserves the previous
72    /// behavior of always inserting a newline on Enter in `multiline` mode
73    /// (e.g. `CodeEditor`'s free-form multiline input).
74    pub fn submit_on_enter(mut self, submit_on_enter: bool) -> Self {
75        self.submit_on_enter = submit_on_enter;
76        self
77    }
78
79    /// Registers a callback fired when the user presses plain Enter while
80    /// [`Self::submit_on_enter`] is `true`. Has no effect otherwise.
81    pub fn on_submit(
82        mut self,
83        handler: impl Fn(&mut Window, &mut Context<Self>) + 'static,
84    ) -> Self {
85        self.on_submit = Some(Rc::new(handler));
86        self
87    }
88
89    /// When `true`, the input no longer accepts keyboard edits (used for
90    /// read-only code previews). Focus/selection styling is unaffected.
91    pub fn read_only(mut self, read_only: bool) -> Self {
92        self.read_only = read_only;
93        self
94    }
95
96    /// Sets the error validation state (red border/ring) when `invalid` is
97    /// true, otherwise clears back to [`InputValidationState::Neutral`].
98    pub fn invalid(mut self, invalid: bool) -> Self {
99        self.validation = if invalid {
100            InputValidationState::Error
101        } else {
102            InputValidationState::Neutral
103        };
104        self
105    }
106
107    /// Sets the success validation state (green border/ring) when `success`
108    /// is true, otherwise clears back to [`InputValidationState::Neutral`].
109    pub fn success(mut self, success: bool) -> Self {
110        self.validation = if success {
111            InputValidationState::Success
112        } else {
113            InputValidationState::Neutral
114        };
115        self
116    }
117
118    /// Sets the warning validation state (amber border/ring) when `warning`
119    /// is true, otherwise clears back to [`InputValidationState::Neutral`].
120    pub fn warning(mut self, warning: bool) -> Self {
121        self.validation = if warning {
122            InputValidationState::Warning
123        } else {
124            InputValidationState::Neutral
125        };
126        self
127    }
128
129    /// The current text content.
130    pub fn text(&self) -> &str {
131        &self.content
132    }
133
134    /// Programmatically sets the text content (e.g. `SearchInput`/`Combobox`
135    /// setting the display text after a selection). Notifies for re-render.
136    pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
137        self.content = text.into();
138        cx.notify();
139    }
140
141    /// Clears the text content (e.g. `SearchInput`'s clear button). Notifies
142    /// for re-render.
143    pub fn clear(&mut self, cx: &mut Context<Self>) {
144        self.content.clear();
145        cx.notify();
146    }
147
148    /// Dynamically toggles read-only mode after construction (e.g.
149    /// `CodeEditor` switching an already-created input between editable and
150    /// preview modes). Notifies for re-render.
151    pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
152        self.read_only = read_only;
153        cx.notify();
154    }
155
156    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
157        if self.read_only {
158            return;
159        }
160        let keystroke = &event.keystroke;
161
162        if self.submit_on_enter && keystroke.key == "enter" {
163            let wants_newline = self.multiline
164                && (keystroke.modifiers.shift
165                    || keystroke.modifiers.control
166                    || keystroke.modifiers.platform);
167            if wants_newline {
168                self.content.push('\n');
169            } else if let Some(on_submit) = self.on_submit.clone() {
170                on_submit(window, cx);
171            }
172            cx.notify();
173            return;
174        }
175
176        // Ignore keyboard shortcuts (cmd/ctrl chords) — only capture text input.
177        if keystroke.modifiers.control || keystroke.modifiers.platform {
178            return;
179        }
180        // When an IME composition is in progress (e.g. Vietnamese/CJK input
181        // method active), the platform sends keystrokes with `key_char: None`
182        // (`is_ime_in_progress()`). Defer those to the input method — it will
183        // commit text via `replace_text_in_range`/`replace_and_mark_text_in_range`.
184        // Handling them here too would double the input.
185        let ime_in_progress = keystroke.is_ime_in_progress();
186        match keystroke.key.as_str() {
187            "backspace" => {
188                if let Some(range) = self.marked_range.take() {
189                    self.content.replace_range(range, "");
190                } else {
191                    self.content.pop();
192                }
193            }
194            "enter" if self.multiline => self.content.push('\n'),
195            "space" if !ime_in_progress => self.content.push(' '),
196            _ => {
197                if !ime_in_progress
198                    && let Some(text) = &keystroke.key_char
199                {
200                    self.content.push_str(text);
201                }
202            }
203        }
204        cx.notify();
205    }
206}
207
208impl Render for TextInput {
209    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
210        let focused = self.focus_handle.is_focused(window);
211        let is_empty = self.content.is_empty();
212        let text_color = if is_empty {
213            semantic::text_placeholder(cx)
214        } else {
215            semantic::text(cx)
216        };
217        let border_color = match self.validation {
218            InputValidationState::Error => palette::danger(500),
219            InputValidationState::Success => palette::success(500),
220            InputValidationState::Warning => palette::warning(500),
221            InputValidationState::Neutral => semantic::border(cx),
222        };
223        let ring_color = match self.validation {
224            InputValidationState::Error => palette::danger(500),
225            InputValidationState::Success => palette::success(500),
226            InputValidationState::Warning => palette::warning(500),
227            InputValidationState::Neutral => palette::primary(500),
228        };
229        let show_cursor = focused && !is_empty && !self.read_only;
230        let cursor = || div().w(px(1.)).h(px(16.)).bg(palette::primary(500));
231
232        // Multiline content is split and rendered one row per line (rather
233        // than a single text child carrying embedded `\n`s) so each typed
234        // newline reliably produces a new visual row, with the blinking
235        // caret appended to the last row.
236        let content: AnyElement = if self.multiline {
237            let text: SharedString = if is_empty {
238                self.placeholder.clone()
239            } else {
240                self.content.clone().into()
241            };
242            let lines: Vec<String> = text.split('\n').map(str::to_string).collect();
243            let last_ix = lines.len().saturating_sub(1);
244            v_flex()
245                .w_full()
246                .children(lines.into_iter().enumerate().map(|(ix, line)| {
247                    h_flex()
248                        .min_h(px(20.))
249                        .items_center()
250                        .gap(DynamicSpacing::Base02.rems(cx))
251                        .child(SharedString::from(line))
252                        .when(ix == last_ix && show_cursor, |this| this.child(cursor()))
253                }))
254                .into_any_element()
255        } else {
256            let display: SharedString = if is_empty {
257                self.placeholder.clone()
258            } else {
259                self.content.clone().into()
260            };
261            h_flex()
262                .flex_wrap()
263                .items_center()
264                .gap(DynamicSpacing::Base02.rems(cx))
265                .child(display)
266                .when(show_cursor, |this| this.child(cursor()))
267                .into_any_element()
268        };
269
270        let field = div()
271            .track_focus(&self.focus_handle)
272            .on_key_down(cx.listener(Self::on_key_down))
273            .on_mouse_down(
274                MouseButton::Left,
275                cx.listener(|this, _event, window, cx| {
276                    window.focus(&this.focus_handle, cx);
277                    cx.notify();
278                }),
279            )
280            .w_full()
281            .when(self.multiline, |this| this.min_h(px(96.)))
282            .px(DynamicSpacing::Base12.px(cx))
283            .py(DynamicSpacing::Base08.px(cx))
284            .rounded_md()
285            .bg(semantic::surface(cx))
286            .border_1()
287            .border_color(border_color)
288            .text_color(text_color)
289            .child(content)
290            .child({
291                // Register an IME input handler for this field so platform
292                // input methods (Vietnamese/CJK IME, dead-key composition)
293                // commit text through `EntityInputHandler` instead of being
294                // dropped. `handle_input` only activates while focused.
295                let focus_handle = self.focus_handle.clone();
296                let entity = cx.entity();
297                canvas(
298                    move |_bounds, _window, _cx| {},
299                    move |bounds, _state, window, cx| {
300                        window.handle_input(
301                            &focus_handle,
302                            ElementInputHandler::new(bounds, entity.clone()),
303                            cx,
304                        );
305                    },
306                )
307                .absolute()
308                .size_full()
309            });
310
311        focus_ring(field, focused, ring_color)
312    }
313}
314
315impl Focusable for TextInput {
316    fn focus_handle(&self, _cx: &App) -> FocusHandle {
317        self.focus_handle.clone()
318    }
319}
320
321impl EntityInputHandler for TextInput {
322    fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context<Self>) -> bool {
323        !self.read_only
324    }
325
326    /// No cursor/selection tracking — this is an append-only field. Returning
327    /// `None` tells the platform there is no active selection (IME commits at
328    /// the end).
329    fn selected_text_range(
330        &mut self,
331        _ignore_disabled_input: bool,
332        _window: &mut Window,
333        _cx: &mut Context<Self>,
334    ) -> Option<UTF16Selection> {
335        None
336    }
337
338    /// The byte range of in-progress IME marked (composition) text.
339    fn marked_text_range(
340        &self,
341        _window: &mut Window,
342        _cx: &mut Context<Self>,
343    ) -> Option<Range<usize>> {
344        self.marked_range.clone()
345    }
346
347    /// Returns the text in the given UTF-16 range (used by the platform to
348    /// read back what's around the composition).
349    fn text_for_range(
350        &mut self,
351        range_utf16: Range<usize>,
352        _adjusted_range: &mut Option<Range<usize>>,
353        _window: &mut Window,
354        _cx: &mut Context<Self>,
355    ) -> Option<String> {
356        let bytes = utf16_range_to_byte_range(&self.content, range_utf16)?;
357        Some(self.content[bytes].to_string())
358    }
359
360    /// IME committed text (or a plain paste/insert). Replaces any in-progress
361    /// marked range, otherwise appends at the end.
362    fn replace_text_in_range(
363        &mut self,
364        range: Option<Range<usize>>,
365        text: &str,
366        _window: &mut Window,
367        cx: &mut Context<Self>,
368    ) {
369        if self.read_only {
370            return;
371        }
372        if let Some(marked) = self.marked_range.take() {
373            self.content.replace_range(marked, text);
374        } else if let Some(range) = range {
375            let bytes = utf16_range_to_byte_range(&self.content, range);
376            if let Some(bytes) = bytes {
377                self.content.replace_range(bytes, text);
378            } else {
379                self.content.push_str(text);
380            }
381        } else {
382            self.content.push_str(text);
383        }
384        cx.notify();
385    }
386
387    /// IME composition in progress — replace the given range (or append) with
388    /// `new_text` and mark it as the active composition range.
389    fn replace_and_mark_text_in_range(
390        &mut self,
391        range: Option<Range<usize>>,
392        new_text: &str,
393        _new_selected_range: Option<Range<usize>>,
394        _window: &mut Window,
395        cx: &mut Context<Self>,
396    ) {
397        if self.read_only {
398            return;
399        }
400        let start_byte = if let Some(marked) = self.marked_range.clone() {
401            self.content.replace_range(marked.clone(), new_text);
402            marked.start
403        } else if let Some(range) = range {
404            let bytes = utf16_range_to_byte_range(&self.content, range);
405            if let Some(bytes) = bytes {
406                self.content.replace_range(bytes.clone(), new_text);
407                bytes.start
408            } else {
409                self.content.push_str(new_text);
410                self.content.len().saturating_sub(new_text.len())
411            }
412        } else {
413            let start = self.content.len();
414            self.content.push_str(new_text);
415            start
416        };
417        let end_byte = start_byte + new_text.len();
418        self.marked_range = Some(start_byte..end_byte);
419        cx.notify();
420    }
421
422    /// Composition finalized/abandoned — drop the mark without changing text.
423    fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
424        if self.marked_range.take().is_some() {
425            cx.notify();
426        }
427    }
428
429    /// Bounds for a UTF-16 range within the field, for IME candidate placement.
430    /// Best-effort: returns the element bounds (candidates appear near the
431    /// field, not pixel-perfect per-character).
432    fn bounds_for_range(
433        &mut self,
434        _range_utf16: Range<usize>,
435        element_bounds: Bounds<Pixels>,
436        _window: &mut Window,
437        _cx: &mut Context<Self>,
438    ) -> Option<Bounds<Pixels>> {
439        Some(element_bounds)
440    }
441
442    fn character_index_for_point(
443        &mut self,
444        _point: gpui::Point<Pixels>,
445        _window: &mut Window,
446        _cx: &mut Context<Self>,
447    ) -> Option<usize> {
448        None
449    }
450}
451
452/// Maps a UTF-16 code-unit range to a byte range within `text` (Rust strings
453/// are UTF-8). Returns `None` if the range is out of bounds.
454fn utf16_range_to_byte_range(text: &str, range_utf16: Range<usize>) -> Option<Range<usize>> {
455    let mut start_byte = None;
456    let mut end_byte = None;
457    let mut utf16_index = 0usize;
458    for (byte_idx, ch) in text.char_indices() {
459        if start_byte.is_none() && utf16_index >= range_utf16.start {
460            start_byte = Some(byte_idx);
461        }
462        utf16_index += ch.len_utf16();
463        if end_byte.is_none() && utf16_index >= range_utf16.end {
464            end_byte = Some(byte_idx + ch.len_utf8());
465            break;
466        }
467    }
468    let start = start_byte.unwrap_or(text.len());
469    let end = end_byte.unwrap_or(text.len());
470    if start <= end && start <= text.len() && end <= text.len() {
471        Some(start..end)
472    } else {
473        None
474    }
475}
476
477/// A multi-line text field. Construct with
478/// `cx.new(|cx| Textarea::new(cx).multiline(true))`.
479pub type Textarea = TextInput;