Skip to main content

gpui_component/input/
input.rs

1use std::rc::Rc;
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5    AccessibleAction, AnyElement, App, DefiniteLength, Edges, ElementId, Entity, Hsla,
6    InteractiveElement as _, IntoElement, ParentElement as _, Rems, RenderOnce, Role, SharedString,
7    StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase, Window, div,
8    px, relative,
9};
10
11use crate::button::{Button, ButtonRounded, ButtonVariants as _};
12use crate::input::clear_button;
13use crate::native_menu::NativeMenu;
14use crate::spinner::Spinner;
15use crate::touch_selection::{EditMenuItem, TouchSelectionOverlay};
16use crate::{ActiveTheme, Colorize, v_flex};
17use crate::{IconName, Size};
18use crate::{RoleOverride, Selectable, StyledExt, h_flex};
19use crate::{Sizable, StyleSized};
20use gpui_base::InputBase as BaseInput;
21use rust_i18n::t;
22
23use super::state::{TextInputState, sync_focused_input_registry};
24use super::{InputContentType, InputState, sync_native_content_type};
25use crate::ThemeStyled as _;
26
27fn accessibility_role(
28    is_multi_line: bool,
29    content_type: Option<InputContentType>,
30    role: RoleOverride,
31) -> Option<Role> {
32    role.resolve(|| {
33        if is_multi_line {
34            return Role::MultilineTextInput;
35        }
36
37        match content_type {
38            None => Role::TextInput,
39            Some(InputContentType::TelephoneNumber) => Role::PhoneNumberInput,
40            Some(InputContentType::EmailAddress) => Role::EmailInput,
41            Some(InputContentType::Url) => Role::UrlInput,
42            Some(InputContentType::Password | InputContentType::NewPassword) => Role::PasswordInput,
43            Some(InputContentType::DateTime) => Role::DateTimeInput,
44            Some(InputContentType::Birthdate) => Role::DateInput,
45            Some(
46                InputContentType::Name
47                | InputContentType::NamePrefix
48                | InputContentType::GivenName
49                | InputContentType::MiddleName
50                | InputContentType::FamilyName
51                | InputContentType::NameSuffix
52                | InputContentType::Nickname
53                | InputContentType::JobTitle
54                | InputContentType::OrganizationName
55                | InputContentType::Location
56                | InputContentType::FullStreetAddress
57                | InputContentType::StreetAddressLine1
58                | InputContentType::StreetAddressLine2
59                | InputContentType::AddressCity
60                | InputContentType::AddressState
61                | InputContentType::AddressCityAndState
62                | InputContentType::Sublocality
63                | InputContentType::CountryName
64                | InputContentType::PostalCode
65                | InputContentType::CreditCardNumber
66                | InputContentType::CreditCardName
67                | InputContentType::CreditCardGivenName
68                | InputContentType::CreditCardMiddleName
69                | InputContentType::CreditCardFamilyName
70                | InputContentType::CreditCardSecurityCode
71                | InputContentType::CreditCardExpiration
72                | InputContentType::CreditCardExpirationMonth
73                | InputContentType::CreditCardExpirationYear
74                | InputContentType::CreditCardType
75                | InputContentType::Username
76                | InputContentType::OneTimeCode
77                | InputContentType::ShipmentTrackingNumber
78                | InputContentType::FlightNumber
79                | InputContentType::BirthdateDay
80                | InputContentType::BirthdateMonth
81                | InputContentType::BirthdateYear
82                | InputContentType::CellularEid
83                | InputContentType::CellularImei,
84            ) => Role::TextInput,
85        }
86    })
87}
88
89fn exposes_accessibility_value(masked: bool, content_type: Option<InputContentType>) -> bool {
90    !masked
91        && !matches!(
92            content_type,
93            Some(InputContentType::Password | InputContentType::NewPassword)
94        )
95}
96
97/// Returns `(background, foreground)` colors for input-like components.
98pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) {
99    if disabled {
100        (
101            cx.theme().input.mix_oklab(cx.theme().transparent, 0.8),
102            cx.theme().muted_foreground,
103        )
104    } else {
105        (cx.theme().input_background(), cx.theme().foreground)
106    }
107}
108
109/// A text input element bind to an [`InputState`].
110#[derive(IntoElement)]
111pub struct Input {
112    id: Option<ElementId>,
113    state: TextInputState,
114    style: StyleRefinement,
115    size: Size,
116    prefix: Option<AnyElement>,
117    suffix: Option<AnyElement>,
118    height: Option<DefiniteLength>,
119    appearance: bool,
120    cleanable: bool,
121    mask_toggle: bool,
122    disabled: bool,
123    readonly: bool,
124    bordered: bool,
125    focus_bordered: bool,
126    tab_index: isize,
127    selected: bool,
128    content_type: Option<InputContentType>,
129    role: RoleOverride,
130    accessibility_id: Option<SharedString>,
131    aria_label: Option<SharedString>,
132
133    /// An optional context menu builder to allow a custom context menu on the input.
134    ///
135    /// If set, this overrides the built-in context menu.
136    context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
137
138    /// An optional paste handler. If set, it is invoked with the clipboard item
139    /// before the default text insertion. Return `true` if handled.
140    paste_handler: Option<Rc<dyn Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool>>,
141}
142
143impl Sizable for Input {
144    fn with_size(mut self, size: impl Into<Size>) -> Self {
145        self.size = size.into();
146        self
147    }
148}
149
150impl Selectable for Input {
151    fn selected(mut self, selected: bool) -> Self {
152        self.selected = selected;
153        self
154    }
155
156    fn is_selected(&self) -> bool {
157        self.selected
158    }
159}
160
161impl crate::FocusableExt for Input {
162    fn focus_ring(mut self, enabled: bool) -> Self {
163        self.focus_bordered = enabled;
164        self
165    }
166
167    fn is_focus_ring_enabled(&self) -> bool {
168        self.focus_bordered
169    }
170}
171
172impl Input {
173    /// Sets the GPUI identity of the input frame. By default it uses the state entity ID.
174    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
175        self.id = Some(id.into());
176        self
177    }
178
179    /// Create a new [`Input`] element bind to the [`InputState`].
180    pub fn new(state: &Entity<InputState>) -> Self {
181        Self::with_state(state.clone().into())
182    }
183
184    /// Builds an input renderer around a state of any kind.
185    ///
186    /// `Textarea` and `Editor` render through this. Application code uses
187    /// [`Input::new`], [`super::Textarea`], or [`super::Editor`].
188    pub(crate) fn from_state(state: impl Into<TextInputState>) -> Self {
189        Self::with_state(state.into())
190    }
191
192    fn with_state(state: TextInputState) -> Self {
193        Self {
194            id: None,
195            state,
196            size: Size::default(),
197            style: StyleRefinement::default(),
198            prefix: None,
199            suffix: None,
200            height: None,
201            appearance: true,
202            cleanable: false,
203            mask_toggle: false,
204            disabled: false,
205            readonly: false,
206            bordered: true,
207            focus_bordered: true,
208            tab_index: 0,
209            selected: false,
210            content_type: None,
211            role: RoleOverride::default(),
212            accessibility_id: None,
213            aria_label: None,
214            context_menu_builder: None,
215            paste_handler: None,
216        }
217    }
218
219    /// Set the developer-assigned identifier exposed to accessibility clients.
220    /// The state this input renders; a compound control reads focus and
221    /// presentation from it.
222    pub(crate) fn state(&self) -> &TextInputState {
223        &self.state
224    }
225
226    pub(crate) fn is_disabled(&self) -> bool {
227        self.disabled
228    }
229
230    pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
231        self.accessibility_id = Some(id.into());
232        self
233    }
234
235    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
236        self.aria_label = Some(label.into());
237        self
238    }
239
240    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
241        self.prefix = Some(prefix.into_any_element());
242        self
243    }
244
245    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
246        self.suffix = Some(suffix.into_any_element());
247        self
248    }
249
250    /// Set full height of the input (Multi-line only).
251    pub fn h_full(mut self) -> Self {
252        self.height = Some(relative(1.));
253        self
254    }
255
256    /// Set height of the input (Multi-line only).
257    pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
258        self.height = Some(height.into());
259        self
260    }
261
262    /// Set the appearance of the input field, if false the input field will no border, background.
263    pub fn appearance(mut self, appearance: bool) -> Self {
264        self.appearance = appearance;
265        self
266    }
267
268    /// Set the bordered for the input, default: true
269    pub fn bordered(mut self, bordered: bool) -> Self {
270        self.bordered = bordered;
271        self
272    }
273
274    /// Set focus border for the input, default is true.
275    pub fn focus_bordered(mut self, bordered: bool) -> Self {
276        self.focus_bordered = bordered;
277        self
278    }
279
280    /// Set whether to show the clear button when the input field is not empty, default is false.
281    pub fn cleanable(mut self, cleanable: bool) -> Self {
282        self.cleanable = cleanable;
283        self
284    }
285
286    /// Set to enable toggle button for password mask state.
287    pub fn mask_toggle(mut self) -> Self {
288        self.mask_toggle = true;
289        self
290    }
291
292    /// Set the semantic content type for password managers and autofill.
293    ///
294    /// This is a component-level semantic hint. It does not change the text
295    /// value or masked rendering state.
296    pub fn content_type(mut self, content_type: InputContentType) -> Self {
297        self.content_type = Some(content_type);
298        self
299    }
300
301    /// Override the accessible role for the input.
302    ///
303    /// If unset, the role is inferred from multi-line mode and content type.
304    pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
305        self.role = role.into();
306        self
307    }
308
309    /// Set to disable the input field.
310    pub fn disabled(mut self, disabled: bool) -> Self {
311        self.disabled = disabled;
312        self
313    }
314
315    /// Set the input field to read-only, default is `false`.
316    ///
317    /// Unlike [`Self::disabled`], a read-only input keeps the normal appearance
318    /// and still can be focused, selected and copied, it only rejects the changes
319    /// made by the user.
320    pub fn readonly(mut self, readonly: bool) -> Self {
321        self.readonly = readonly;
322        self
323    }
324
325    /// Set the tab index for the input, default is 0.
326    pub fn tab_index(mut self, index: isize) -> Self {
327        self.tab_index = index;
328        self
329    }
330
331    /// Sets a custom context menu builder for the input, shown as a native OS menu.
332    ///
333    /// If set, this overrides the built-in right-click context menu.
334    pub fn context_menu(
335        mut self,
336        f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
337    ) -> Self {
338        self.context_menu_builder = Some(Rc::new(f));
339        self
340    }
341
342    /// Intercept paste payloads (images, files) before the default text insertion.
343    ///
344    /// The handler receives the clipboard item and returns whether it took the
345    /// paste: `true` stops the `input::Paste` action so the input inserts
346    /// nothing, `false` lets it reach the engine, which inserts
347    /// `clipboard.text()` as today. Text stays in the `Rope`; images and
348    /// copied files (`ClipboardEntry::Image`, `ClipboardEntry::ExternalPaths`)
349    /// belong in app-owned state beside the input (e.g. `Attachment`s), never
350    /// inside it.
351    ///
352    /// Known limit: on web `read_from_clipboard()` is `None` (text arrives
353    /// through the platform input handler); image paste there needs
354    /// `read_from_clipboard_async` and permission, out of scope here.
355    pub fn on_paste(
356        mut self,
357        handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
358    ) -> Self {
359        self.paste_handler = Some(Rc::new(handler));
360        self
361    }
362
363    /// The handles and the edit menu of the selection a long press made.
364    ///
365    /// The menu offers what the native context menu would: Cut, Copy, Paste
366    /// and Select All, leaving out what cannot apply right now rather than
367    /// disabling it. Cut, Copy and Paste go through the input's actions, so a
368    /// custom key binding or an open completion menu sees them the same way.
369    fn render_touch_selection(
370        state: &TextInputState,
371        window: &Window,
372        cx: &App,
373    ) -> Vec<AnyElement> {
374        if state.touch_selection(cx).is_none() {
375            return Vec::new();
376        }
377        let capabilities = state.context_menu_capabilities(cx);
378        let editable = capabilities.is_editable();
379        let copyable = capabilities.is_copyable();
380        // Offered whenever the text can change, without peeking at the
381        // clipboard: on iOS every read of it shows the system's paste banner,
382        // and an empty clipboard pastes nothing.
383        let pasteable = editable;
384        let selectable = state.text(cx).len() > 0 && !state.is_all_selected(cx);
385        let focus_handle = state.presentation(cx).focus_handle().clone();
386
387        let dispatch = {
388            let focus_handle = focus_handle.clone();
389            move |action: &dyn gpui::Action, window: &mut Window, cx: &mut App| {
390                focus_handle.dispatch_action(action, window, cx);
391            }
392        };
393        let mut items = Vec::with_capacity(4);
394        if editable && copyable {
395            let dispatch = dispatch.clone();
396            items.push(EditMenuItem::new(t!("Input.Cut"), move |window, cx| {
397                dispatch(&gpui_base::input::Cut, window, cx);
398            }));
399        }
400        if copyable {
401            let dispatch = dispatch.clone();
402            let state = state.clone();
403            items.push(EditMenuItem::new(t!("Input.Copy"), move |window, cx| {
404                dispatch(&gpui_base::input::Copy, window, cx);
405                state.close_edit_menu(cx);
406            }));
407        }
408        if pasteable {
409            let dispatch = dispatch.clone();
410            items.push(EditMenuItem::new(t!("Input.Paste"), move |window, cx| {
411                dispatch(&gpui_base::input::Paste, window, cx);
412            }));
413        }
414        if selectable {
415            let state = state.clone();
416            items.push(EditMenuItem::new(
417                t!("Input.Select All"),
418                move |window, cx| state.select_all_from_edit_menu(window, cx),
419            ));
420        }
421
422        let drag_state = state.clone();
423        let source_state = state.clone();
424        TouchSelectionOverlay::new(
425            ("input-touch-selection", state.entity_id()),
426            move |_, cx| source_state.touch_selection(cx),
427        )
428        .handles(move |edge, phase, position, _, cx| match phase {
429            TouchPhase::Started => drag_state.begin_edge_drag(edge, position, cx),
430            TouchPhase::Moved => drag_state.update_edge_drag(position, cx),
431            TouchPhase::Ended | TouchPhase::Cancelled => drag_state.end_edge_drag(cx),
432        })
433        .items(items)
434        .into_elements(window, cx)
435    }
436
437    fn render_toggle_mask_button(state: &TextInputState, cx: &App) -> impl IntoElement {
438        let masked = state.presentation(cx).is_masked();
439        Button::new("toggle-mask")
440            .icon(if masked {
441                IconName::Eye
442            } else {
443                IconName::EyeOff
444            })
445            .xsmall()
446            .text()
447            .tab_stop(false)
448            .on_click({
449                let state = state.clone();
450                move |_, window, cx| state.toggle_masked(window, cx)
451            })
452    }
453
454    fn handle_accessibility_set_value(
455        state: &TextInputState,
456        data: Option<&gpui::accesskit::ActionData>,
457        window: &mut Window,
458        cx: &mut App,
459    ) {
460        let Some(gpui::accesskit::ActionData::Value(value)) = data else {
461            return;
462        };
463        state.replace_all(value.to_string(), window, cx);
464    }
465
466    /// This method must after the refine_style.
467    fn render_editor(
468        input_state: TextInputState,
469        search_panel: Option<AnyElement>,
470        _: &Window,
471    ) -> impl IntoElement {
472        v_flex().size_full().children(search_panel).child(
473            div()
474                .relative()
475                .flex_1()
476                .child(input_state.into_any_element()),
477        )
478    }
479}
480
481impl Styled for Input {
482    fn style(&mut self) -> &mut StyleRefinement {
483        &mut self.style
484    }
485}
486
487impl RenderOnce for Input {
488    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
489        const LINE_HEIGHT: Rems = Rems(1.25);
490        let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
491        let state = self.state.clone();
492        // Which kind of input this registers as follows from the state itself.
493        sync_focused_input_registry(&state, window, cx);
494
495        state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx);
496        state.set_editor_style(
497            gpui_base::input::InputEditorStyle {
498                foreground: cx.theme().foreground,
499                muted_foreground: cx.theme().muted_foreground,
500                background: cx.theme().editor_background(),
501                border: cx.theme().border,
502                selection: cx.theme().selection,
503                caret: cx.theme().caret,
504                diagnostics: gpui_base::input::DiagnosticColors {
505                    error: cx.theme().highlight_theme.style.status.error(cx),
506                    warning: cx.theme().highlight_theme.style.status.warning(cx),
507                    info: cx.theme().highlight_theme.style.status.info(cx),
508                    hint: cx.theme().highlight_theme.style.status.hint(cx),
509                },
510                highlight_styles: cx.theme().highlight_theme.clone(),
511                editor_invisible: cx.theme().highlight_theme.style.editor_invisible,
512                editor_active_line: cx.theme().highlight_theme.style.editor_active_line,
513                editor_gutter_background: cx.theme().highlight_theme.style.editor_gutter_background,
514                fold_icon_renderer: Some(Rc::new(|ix, is_folded| {
515                    Button::new(("fold-icon", ix))
516                        .ghost()
517                        .icon(if is_folded {
518                            IconName::ChevronRight
519                        } else {
520                            IconName::ChevronDown
521                        })
522                        .xsmall()
523                        .rounded(ButtonRounded::Small)
524                        .size(px(14.))
525                        .selected(is_folded)
526                        .into_any_element()
527                })),
528            },
529            cx,
530        );
531        state.set_editor_paddings(
532            if state.presentation(cx).is_multi_line() {
533                Edges {
534                    top: self.size.input_py(),
535                    right: self.size.input_px(),
536                    bottom: self.size.input_py(),
537                    left: self.size.input_px(),
538                }
539            } else {
540                Edges::default()
541            },
542            cx,
543        );
544        state.set_disabled(self.disabled, cx);
545        state.set_readonly(self.readonly, cx);
546        state.set_text_align(text_align, cx);
547        let custom = self.context_menu_builder.clone();
548        state.on_context_menu(
549            Rc::new(move |_, capabilities, position, window, cx| {
550                let menu = if let Some(custom) = custom.as_ref() {
551                    custom(NativeMenu::new(), window, cx)
552                } else {
553                    let enabled = !capabilities.is_disabled();
554                    // A read-only input can still navigate the code, it only
555                    // rejects the items that would change the text.
556                    let editable = enabled && !capabilities.is_readonly();
557                    let mut menu = NativeMenu::new();
558                    if capabilities.is_code_editor() {
559                        menu = menu
560                            .menu_with_disabled(
561                                t!("Input.Go to Definition"),
562                                !(enabled && capabilities.has_definition()),
563                                Box::new(gpui_base::input::GoToDefinition),
564                            )
565                            .menu_with_disabled(
566                                t!("Input.Show Code Actions"),
567                                !(editable && capabilities.has_code_actions()),
568                                Box::new(gpui_base::input::ToggleCodeActions),
569                            )
570                            .separator();
571                    }
572                    menu.menu_with_disabled(
573                        t!("Input.Cut"),
574                        !(editable && capabilities.is_copyable()),
575                        Box::new(gpui_base::input::Cut),
576                    )
577                    .menu_with_disabled(
578                        t!("Input.Copy"),
579                        !capabilities.is_copyable(),
580                        Box::new(gpui_base::input::Copy),
581                    )
582                    .menu_with_disabled(
583                        t!("Input.Paste"),
584                        !(editable && cx.read_from_clipboard().is_some()),
585                        Box::new(gpui_base::input::Paste),
586                    )
587                    .separator()
588                    .menu(
589                        t!("Input.Select All"),
590                        Box::new(gpui_base::input::SelectAll),
591                    )
592                };
593                menu.show(position, window, cx);
594            }),
595            cx,
596        );
597        // The engine ignores `Paste` on a read-only or disabled input; the hook
598        // must not see a paste the input itself would refuse.
599        let paste_handler = self
600            .paste_handler
601            .clone()
602            .filter(|_| state.presentation(cx).is_editable());
603        let mut overlays = state.render_overlays(window, cx);
604        overlays
605            .floating
606            .extend(Self::render_touch_selection(&state, window, cx));
607
608        let presentation = state.presentation(cx);
609        let content_type = self.content_type;
610        let disabled = self.disabled;
611        let is_multi_line = presentation.is_multi_line();
612        let accessibility_role = accessibility_role(is_multi_line, content_type, self.role);
613        let accessibility_state = state.clone();
614        // Tests read the same accessibility value as assistive technology.
615        // Avoid materializing the rope in normal builds without a client.
616        let accessibility_value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
617            && exposes_accessibility_value(presentation.is_masked(), content_type))
618        .then(|| state.text(cx).to_string());
619        let input_focused =
620            presentation.focus_handle().is_focused(window) && !presentation.is_disabled();
621        if input_focused {
622            sync_native_content_type(window, content_type, presentation.is_editable());
623        }
624        let frame_focus_handle = window
625            .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| {
626                cx.focus_handle()
627            })
628            .read(cx)
629            .clone();
630        let focused = input_focused
631            || (frame_focus_handle.contains_focused(window, cx) && !presentation.is_disabled());
632
633        let gap_x = match self.size {
634            Size::Small => px(4.),
635            Size::Large => px(8.),
636            _ => px(6.),
637        };
638
639        let (bg, _) = input_style(presentation.is_disabled(), cx);
640        let bg = if presentation.is_code_editor() {
641            cx.theme().editor_background()
642        } else {
643            bg
644        };
645        let bg = if presentation.is_disabled() {
646            bg.opacity(0.5)
647        } else {
648            bg
649        };
650        let prefix = self.prefix;
651        let suffix = self.suffix;
652        let show_clear_button = self.cleanable
653            && presentation.is_editable()
654            && !presentation.is_loading()
655            && state.text(cx).len() > 0
656            && !presentation.is_multi_line();
657        let has_suffix =
658            suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
659
660        let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
661
662        // Don't use a mask-derived placeholder ("(___)___-___") as an aria_label fallback.
663        let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
664
665        let aria_label = match self.aria_label {
666            Some(label) => Some(label),
667            None if placeholder_is_mask => None,
668            None => placeholder.clone(),
669        };
670        let id = self
671            .id
672            .unwrap_or_else(|| ("input", state.entity_id()).into());
673        BaseInput::new(id)
674            .focused(focused)
675            .disabled(disabled)
676            .track_focus(&frame_focus_handle)
677            .styles(|styles| {
678                styles.focused(|style| {
679                    style.when(
680                        self.appearance && self.bordered && self.focus_bordered,
681                        |style| style.border_1().border_color(cx.theme().ring),
682                    )
683                })
684            })
685            .role(accessibility_role)
686            .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
687            .when_some(aria_label, |this, label| this.aria_label(label))
688            .when_some(placeholder, |this, placeholder| {
689                this.aria_placeholder(placeholder)
690            })
691            .when_some(accessibility_value, |this, value| this.aria_value(value))
692            .when(!disabled, |this| {
693                this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
694                    Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
695                })
696            })
697            .flex()
698            .size_full()
699            .line_height(LINE_HEIGHT)
700            .when(!is_multi_line, |this| {
701                this.input_px(self.size).input_py(self.size)
702            })
703            .input_h(self.size)
704            .input_text_size(self.size)
705            .items_center()
706            .when(presentation.is_multi_line(), |this| {
707                this.h_auto()
708                    .when_some(self.height, |this, height| this.h(height))
709            })
710            .when(self.appearance, |this| {
711                this.bg(bg)
712                    .rounded(cx.theme().radius)
713                    .when(self.bordered, |this| {
714                        this.border_1().border_color(cx.theme().input)
715                    })
716            })
717            .items_center()
718            .gap(gap_x)
719            .refine_style(&self.style)
720            .when(
721                focused && self.appearance && self.bordered && self.focus_bordered,
722                |this| this.focus_ring_style(window, cx),
723            )
724            .children(prefix.map(|p| {
725                div()
726                    .when(presentation.is_disabled(), |this| this.opacity(0.5))
727                    .child(p)
728            }))
729            .when(presentation.is_multi_line(), |this| {
730                this.child(Self::render_editor(state.clone(), overlays.search, window))
731            })
732            .when(!presentation.is_multi_line(), |this| {
733                this.child(state.clone().into_any_element())
734            })
735            .when(has_suffix, |this| {
736                this.pr(self.size.input_px()).child(
737                    h_flex()
738                        .id("suffix")
739                        .gap(gap_x)
740                        .items_center()
741                        .cursor_default()
742                        .when(presentation.is_disabled(), |this| this.opacity(0.5))
743                        .when(presentation.is_loading(), |this| {
744                            this.child(Spinner::new().color(cx.theme().muted_foreground))
745                        })
746                        .when(self.mask_toggle, |this| {
747                            this.child(Self::render_toggle_mask_button(&state, cx))
748                        })
749                        .when(show_clear_button, |this| {
750                            this.child(clear_button(cx).on_click({
751                                let state = state.clone();
752                                move |_, window, cx| {
753                                    state.clean(window, cx);
754                                    state.focus(window, cx);
755                                }
756                            }))
757                        })
758                        .children(suffix),
759                )
760            })
761            .relative()
762            .children(overlays.floating)
763            .when_some(paste_handler, |this, handler| {
764                this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
765                    if let Some(clipboard) = cx.read_from_clipboard() {
766                        if handler(&clipboard, window, cx) {
767                            cx.stop_propagation();
768                        }
769                    }
770                })
771            })
772            .render(window, cx)
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use crate::input::AnyInputState;
780
781    #[test]
782    fn content_types_map_to_accessibility_roles() {
783        let cases = [
784            (None, Role::TextInput),
785            (Some(InputContentType::Name), Role::TextInput),
786            (Some(InputContentType::NamePrefix), Role::TextInput),
787            (Some(InputContentType::GivenName), Role::TextInput),
788            (Some(InputContentType::MiddleName), Role::TextInput),
789            (Some(InputContentType::FamilyName), Role::TextInput),
790            (Some(InputContentType::NameSuffix), Role::TextInput),
791            (Some(InputContentType::Nickname), Role::TextInput),
792            (Some(InputContentType::JobTitle), Role::TextInput),
793            (Some(InputContentType::OrganizationName), Role::TextInput),
794            (Some(InputContentType::Location), Role::TextInput),
795            (Some(InputContentType::FullStreetAddress), Role::TextInput),
796            (Some(InputContentType::StreetAddressLine1), Role::TextInput),
797            (Some(InputContentType::StreetAddressLine2), Role::TextInput),
798            (Some(InputContentType::AddressCity), Role::TextInput),
799            (Some(InputContentType::AddressState), Role::TextInput),
800            (Some(InputContentType::AddressCityAndState), Role::TextInput),
801            (Some(InputContentType::Sublocality), Role::TextInput),
802            (Some(InputContentType::CountryName), Role::TextInput),
803            (Some(InputContentType::PostalCode), Role::TextInput),
804            (
805                Some(InputContentType::TelephoneNumber),
806                Role::PhoneNumberInput,
807            ),
808            (Some(InputContentType::EmailAddress), Role::EmailInput),
809            (Some(InputContentType::Url), Role::UrlInput),
810            (Some(InputContentType::CreditCardNumber), Role::TextInput),
811            (Some(InputContentType::CreditCardName), Role::TextInput),
812            (Some(InputContentType::CreditCardGivenName), Role::TextInput),
813            (
814                Some(InputContentType::CreditCardMiddleName),
815                Role::TextInput,
816            ),
817            (
818                Some(InputContentType::CreditCardFamilyName),
819                Role::TextInput,
820            ),
821            (
822                Some(InputContentType::CreditCardSecurityCode),
823                Role::TextInput,
824            ),
825            (
826                Some(InputContentType::CreditCardExpiration),
827                Role::TextInput,
828            ),
829            (
830                Some(InputContentType::CreditCardExpirationMonth),
831                Role::TextInput,
832            ),
833            (
834                Some(InputContentType::CreditCardExpirationYear),
835                Role::TextInput,
836            ),
837            (Some(InputContentType::CreditCardType), Role::TextInput),
838            (Some(InputContentType::Username), Role::TextInput),
839            (Some(InputContentType::Password), Role::PasswordInput),
840            (Some(InputContentType::NewPassword), Role::PasswordInput),
841            (Some(InputContentType::OneTimeCode), Role::TextInput),
842            (
843                Some(InputContentType::ShipmentTrackingNumber),
844                Role::TextInput,
845            ),
846            (Some(InputContentType::FlightNumber), Role::TextInput),
847            (Some(InputContentType::DateTime), Role::DateTimeInput),
848            (Some(InputContentType::Birthdate), Role::DateInput),
849            (Some(InputContentType::BirthdateDay), Role::TextInput),
850            (Some(InputContentType::BirthdateMonth), Role::TextInput),
851            (Some(InputContentType::BirthdateYear), Role::TextInput),
852            (Some(InputContentType::CellularEid), Role::TextInput),
853            (Some(InputContentType::CellularImei), Role::TextInput),
854        ];
855
856        for (content_type, role) in cases {
857            assert_eq!(
858                accessibility_role(false, content_type, RoleOverride::Implicit),
859                Some(role)
860            );
861        }
862    }
863
864    #[test]
865    fn multiline_inputs_keep_multiline_accessibility_role() {
866        assert_eq!(
867            accessibility_role(
868                true,
869                Some(InputContentType::Password),
870                RoleOverride::Implicit
871            ),
872            Some(Role::MultilineTextInput)
873        );
874    }
875
876    #[test]
877    fn explicit_accessibility_role_overrides_defaults() {
878        assert_eq!(
879            accessibility_role(
880                false,
881                Some(InputContentType::Password),
882                Role::TextInput.into()
883            ),
884            Some(Role::TextInput)
885        );
886        assert_eq!(
887            accessibility_role(
888                true,
889                Some(InputContentType::Password),
890                Role::TextInput.into()
891            ),
892            Some(Role::TextInput)
893        );
894    }
895
896    #[test]
897    fn presentational_role_emits_no_accessibility_node() {
898        assert_eq!(
899            accessibility_role(
900                false,
901                Some(InputContentType::Password),
902                RoleOverride::Presentational
903            ),
904            None
905        );
906        assert_eq!(
907            accessibility_role(true, None, RoleOverride::Presentational),
908            None
909        );
910    }
911
912    #[test]
913    fn role_option_converts_to_the_matching_override() {
914        assert_eq!(
915            RoleOverride::from(Some(Role::Button)),
916            RoleOverride::Role(Role::Button)
917        );
918        assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
919    }
920
921    #[gpui::test]
922    fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
923        use gpui::{AppContext as _, Render};
924
925        struct Probe;
926        impl Render for Probe {
927            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
928                div()
929            }
930        }
931
932        cx.update(crate::init);
933        let _ = cx.add_window_view(|window, cx| {
934            let state = cx.new(|cx| InputState::new(window, cx));
935
936            assert!(Input::new(&state).paste_handler.is_none());
937            let input = Input::new(&state).on_paste(|_, _, _| true);
938            assert!(input.paste_handler.is_some());
939            Probe
940        });
941    }
942
943    #[gpui::test]
944    fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
945        use crate::ElementExt as _;
946        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
947        use std::sync::{Arc, Mutex};
948
949        type EmittedState = Option<(Option<String>, bool)>;
950
951        struct InputA11yProbe {
952            state: Entity<InputState>,
953            emitted: Arc<Mutex<EmittedState>>,
954        }
955
956        impl Render for InputA11yProbe {
957            fn render(
958                &mut self,
959                _window: &mut Window,
960                _cx: &mut gpui::Context<Self>,
961            ) -> impl IntoElement {
962                let state = self.state.clone();
963                let emitted = self.emitted.clone();
964                div().on_prepaint(move |_, window, cx| {
965                    let input = Input::new(&state).render(window, cx).into_element();
966                    let mut node = gpui::accesskit::Node::new(Role::TextInput);
967                    input.write_a11y_info(&mut node);
968                    *emitted.lock().unwrap() = Some((
969                        node.value().map(ToOwned::to_owned),
970                        node.supports_action(AccessibleAction::SetValue),
971                    ));
972                })
973            }
974        }
975
976        cx.update(crate::init);
977        let emitted = Arc::new(Mutex::new(None));
978        let captured = emitted.clone();
979        let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
980            state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
981            emitted,
982        });
983        cx.update(|window, cx| {
984            let _ = window.draw(cx);
985        });
986        // Normal builds leave the value lazy without an accessibility client;
987        // test-support reads that same production value path eagerly.
988        let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
989        assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
990
991        let state = probe.read_with(cx, |probe, _| probe.state.clone());
992        let base: TextInputState = state.clone().into();
993        cx.update(|window, cx| {
994            Input::handle_accessibility_set_value(&base, None, window, cx);
995        });
996        assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
997
998        let action = gpui::accesskit::ActionData::Value("updated".into());
999        cx.update(|window, cx| {
1000            Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1001        });
1002        assert_eq!(state.read_with(cx, |state, _| state.value()), "updated");
1003    }
1004
1005    #[gpui::test]
1006    fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
1007        use crate::ElementExt as _;
1008        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1009        use std::sync::{Arc, Mutex};
1010
1011        type EmittedIds = Vec<Option<String>>;
1012
1013        struct InputA11yProbe {
1014            state: Entity<InputState>,
1015            emitted: Arc<Mutex<EmittedIds>>,
1016        }
1017
1018        impl Render for InputA11yProbe {
1019            fn render(
1020                &mut self,
1021                _window: &mut Window,
1022                _cx: &mut gpui::Context<Self>,
1023            ) -> impl IntoElement {
1024                let state = self.state.clone();
1025                let emitted = self.emitted.clone();
1026                div().on_prepaint(move |_, window, cx| {
1027                    let mut author_id_of = |input: Input| {
1028                        let mut node = gpui::accesskit::Node::new(Role::TextInput);
1029                        input
1030                            .render(window, cx)
1031                            .into_element()
1032                            .write_a11y_info(&mut node);
1033                        node.author_id().map(ToOwned::to_owned)
1034                    };
1035
1036                    *emitted.lock().unwrap() = vec![
1037                        author_id_of(Input::new(&state)),
1038                        author_id_of(Input::new(&state).accessibility_id("search.query")),
1039                    ];
1040                })
1041            }
1042        }
1043
1044        cx.update(crate::init);
1045        let emitted = Arc::new(Mutex::new(Vec::new()));
1046        let captured = emitted.clone();
1047        let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1048            state: cx.new(|cx| InputState::new(window, cx)),
1049            emitted,
1050        });
1051        cx.update(|window, cx| {
1052            let _ = window.draw(cx);
1053        });
1054
1055        assert_eq!(
1056            *captured.lock().unwrap(),
1057            vec![None, Some("search.query".into())]
1058        );
1059    }
1060
1061    #[test]
1062    fn accessibility_value_is_hidden_for_secret_inputs() {
1063        assert!(exposes_accessibility_value(false, None));
1064        assert!(!exposes_accessibility_value(true, None));
1065        assert!(!exposes_accessibility_value(
1066            false,
1067            Some(InputContentType::Password)
1068        ));
1069        assert!(!exposes_accessibility_value(
1070            false,
1071            Some(InputContentType::NewPassword)
1072        ));
1073    }
1074
1075    #[gpui::test]
1076    fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1077        use crate::{Root, WindowExt as _};
1078        use gpui::{AppContext as _, Render};
1079
1080        struct Probe {
1081            input: Entity<InputState>,
1082            textarea: Entity<crate::input::TextareaState>,
1083            editor: Entity<crate::input::EditorState>,
1084            otp: Entity<gpui_base::OtpState>,
1085            other: gpui::FocusHandle,
1086        }
1087        impl Render for Probe {
1088            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1089                div()
1090                    .child(div().track_focus(&self.other))
1091                    .child(Input::new(&self.input))
1092                    .child(crate::input::Textarea::new(&self.textarea))
1093                    .child(crate::input::Editor::new(&self.editor))
1094                    .child(crate::input::OtpInput::new(&self.otp))
1095            }
1096        }
1097
1098        cx.update(crate::init);
1099        let mut input = None;
1100        let mut textarea = None;
1101        let mut editor = None;
1102        let mut other_focus = None;
1103        let mut otp = None;
1104        let window = cx.update(|cx| {
1105            cx.open_window(Default::default(), |window, cx| {
1106                let state = cx.new(|cx| InputState::new(window, cx));
1107                let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1108                let editor_state =
1109                    cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1110                let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1111                input = Some(state.clone());
1112                textarea = Some(textarea_state.clone());
1113                editor = Some(editor_state.clone());
1114                otp = Some(otp_state.clone());
1115                let other = cx.focus_handle();
1116                other_focus = Some(other.clone());
1117                let probe = cx.new(|_| Probe {
1118                    input: state,
1119                    textarea: textarea_state,
1120                    editor: editor_state,
1121                    otp: otp_state,
1122                    other,
1123                });
1124                cx.new(|cx| Root::new(probe, window, cx))
1125            })
1126            .unwrap()
1127        });
1128        let input = input.unwrap();
1129        let textarea = textarea.unwrap();
1130        let editor = editor.unwrap();
1131        let otp = otp.unwrap();
1132        let other_focus = other_focus.unwrap();
1133        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1134
1135        // Focusing each kind of input registers it, and blurring clears it.
1136        let cases: Vec<AnyInputState> = vec![
1137            input.clone().into(),
1138            textarea.clone().into(),
1139            editor.clone().into(),
1140            otp.clone().into(),
1141        ];
1142        for expected in cases {
1143            cx.update(|window, cx| {
1144                let _ = window.draw(cx);
1145            });
1146            cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1147            cx.run_until_parked();
1148            cx.update(|window, cx| {
1149                let _ = window.draw(cx);
1150            });
1151            assert_eq!(
1152                cx.update(|window, cx| window.focused_input(cx)),
1153                Some(expected)
1154            );
1155
1156            cx.update(|window, cx| other_focus.clone().focus(window, cx));
1157            cx.run_until_parked();
1158            cx.update(|window, cx| {
1159                let _ = window.draw(cx);
1160            });
1161            assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1162        }
1163    }
1164
1165    #[gpui::test]
1166    fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1167        use crate::{Root, WindowExt as _};
1168        use gpui::{AppContext as _, Render};
1169
1170        struct Probe {
1171            input: Entity<InputState>,
1172            show_input: bool,
1173            other: gpui::FocusHandle,
1174        }
1175        impl Render for Probe {
1176            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1177                let base = div().child(div().track_focus(&self.other));
1178                if self.show_input {
1179                    base.child(Input::new(&self.input))
1180                } else {
1181                    base
1182                }
1183            }
1184        }
1185
1186        cx.update(crate::init);
1187        let mut input = None;
1188        let mut other_focus = None;
1189        let mut probe_entity = None;
1190        let window = cx.update(|cx| {
1191            cx.open_window(Default::default(), |window, cx| {
1192                let state = cx.new(|cx| InputState::new(window, cx));
1193                input = Some(state.clone());
1194                let other = cx.focus_handle();
1195                other_focus = Some(other.clone());
1196                let probe = cx.new(|_| Probe {
1197                    input: state,
1198                    show_input: true,
1199                    other,
1200                });
1201                probe_entity = Some(probe.clone());
1202                cx.new(|cx| Root::new(probe, window, cx))
1203            })
1204            .unwrap()
1205        });
1206        let input: AnyInputState = input.unwrap().into();
1207        let other_focus = other_focus.unwrap();
1208        let probe = probe_entity.unwrap();
1209        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1210
1211        cx.update(|window, cx| {
1212            let _ = window.draw(cx);
1213        });
1214        cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1215        cx.run_until_parked();
1216        cx.update(|window, cx| {
1217            let _ = window.draw(cx);
1218        });
1219        assert_eq!(
1220            cx.update(|window, cx| window.focused_input(cx)),
1221            Some(input.clone())
1222        );
1223
1224        // Remove the input from the tree while it holds focus and move focus
1225        // elsewhere in the same update (e.g. closing a sidebar containing the
1226        // input) — the input never re-renders to unregister itself.
1227        cx.update(|window, cx| {
1228            probe.update(cx, |probe, cx| {
1229                probe.show_input = false;
1230                cx.notify();
1231            });
1232            other_focus.focus(window, cx);
1233        });
1234        cx.run_until_parked();
1235        cx.update(|window, cx| {
1236            let _ = window.draw(cx);
1237        });
1238        assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1239        assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1240    }
1241}