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