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