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