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