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, MouseButton, ParentElement as _, Rems, RenderOnce, Role,
7    SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase,
8    Window, div, 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 = !presentation.is_disabled()
698            && (state.has_selection_focus(window, cx)
699                || frame_focus_handle.contains_focused(window, cx));
700
701        let gap_x = match self.size {
702            Size::Small => px(4.),
703            Size::Large => px(8.),
704            _ => px(6.),
705        };
706
707        let (bg, _) = input_style(presentation.is_disabled(), cx);
708        let bg = if presentation.is_code_editor() {
709            cx.theme().editor_background()
710        } else {
711            bg
712        };
713        let bg = if presentation.is_disabled() {
714            bg.opacity(0.5)
715        } else {
716            bg
717        };
718        let prefix = self.prefix;
719        let suffix = self.suffix;
720        let show_clear_button = self.cleanable
721            && presentation.is_editable()
722            && !presentation.is_loading()
723            && state.text(cx).len() > 0
724            && !presentation.is_multi_line();
725        let has_suffix =
726            suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
727
728        let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
729
730        // Don't use a mask-derived placeholder ("(___)___-___") as an aria_label fallback.
731        let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
732
733        let aria_label = match self.aria_label {
734            Some(label) => Some(label),
735            None if placeholder_is_mask => None,
736            None => placeholder.clone(),
737        };
738        let id = self
739            .id
740            .unwrap_or_else(|| ("input", state.entity_id()).into());
741        BaseInput::new(id)
742            .focused(focused)
743            .disabled(disabled)
744            .track_focus(&frame_focus_handle)
745            .on_mouse_down(MouseButton::Right, {
746                let state = state.clone();
747                move |_, window, cx| {
748                    // The text element has already taken focus. Publish its state
749                    // before an outer ContextMenu resolves the selection owner;
750                    // render-time registration still names the previous input.
751                    sync_focused_input_registry(&state, window, cx);
752                }
753            })
754            .when(disabled, |this| {
755                this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
756            })
757            .styles(|styles| {
758                styles.focused(|style| {
759                    style.when(
760                        self.appearance && self.bordered && self.focus_bordered,
761                        |style| style.border_1().border_color(cx.theme().ring),
762                    )
763                })
764            })
765            .role(accessibility_role)
766            .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
767            .when_some(aria_label, |this, label| this.aria_label(label))
768            .when_some(placeholder, |this, placeholder| {
769                this.aria_placeholder(placeholder)
770            })
771            .when_some(accessibility_value, |this, value| this.aria_value(value))
772            .on_a11y_action(AccessibleAction::Focus, {
773                let state = state.clone();
774                move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx)
775            })
776            .when(presentation.is_editable(), |this| {
777                this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
778                    Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
779                })
780            })
781            .flex()
782            .size_full()
783            .line_height(LINE_HEIGHT)
784            .when(!is_multi_line, |this| {
785                this.input_px(self.size).input_py(self.size)
786            })
787            .input_h(self.size)
788            .input_text_size(self.size)
789            .items_center()
790            .when(presentation.is_multi_line(), |this| {
791                this.h_auto()
792                    .when_some(self.height, |this, height| this.h(height))
793            })
794            .when(self.appearance, |this| {
795                this.bg(bg)
796                    .rounded(cx.theme().radius)
797                    .when(self.bordered, |this| {
798                        this.border_1().border_color(cx.theme().input)
799                    })
800            })
801            .items_center()
802            .gap(gap_x)
803            .refine_style(&self.style)
804            .when(
805                focused && self.appearance && self.bordered && self.focus_bordered,
806                |this| this.focus_ring_style(window, cx),
807            )
808            .children(prefix.map(|p| {
809                div()
810                    .when(presentation.is_disabled(), |this| this.opacity(0.5))
811                    .child(p)
812            }))
813            .when(presentation.is_multi_line(), |this| {
814                this.child(Self::render_editor(state.clone(), overlays.search, window))
815            })
816            .when(!presentation.is_multi_line(), |this| {
817                this.child(state.clone().into_any_element())
818            })
819            .when(has_suffix, |this| {
820                this.pr(self.size.input_px()).child(
821                    h_flex()
822                        .id("suffix")
823                        .gap(gap_x)
824                        .items_center()
825                        .cursor_default()
826                        .when(presentation.is_disabled(), |this| this.opacity(0.5))
827                        .when(presentation.is_loading(), |this| {
828                            this.child(Spinner::new().color(cx.theme().muted_foreground))
829                        })
830                        .when(self.mask_toggle, |this| {
831                            this.child(Self::render_toggle_mask_button(&state, cx))
832                        })
833                        .when(show_clear_button, |this| {
834                            this.child(clear_button(cx).on_click({
835                                let state = state.clone();
836                                move |_, window, cx| {
837                                    state.clean(window, cx);
838                                    state.focus(window, cx);
839                                }
840                            }))
841                        })
842                        .children(suffix),
843                )
844            })
845            .relative()
846            .children(overlays.floating)
847            .when_some(paste_handler, |this, handler| {
848                this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
849                    if let Some(clipboard) = cx.read_from_clipboard() {
850                        if handler(&clipboard, window, cx) {
851                            cx.stop_propagation();
852                        }
853                    }
854                })
855            })
856            .render(window, cx)
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::input::AnyInputState;
864
865    #[test]
866    fn content_types_map_to_accessibility_roles() {
867        let cases = [
868            (None, Role::TextInput),
869            (Some(InputContentType::Name), Role::TextInput),
870            (Some(InputContentType::NamePrefix), Role::TextInput),
871            (Some(InputContentType::GivenName), Role::TextInput),
872            (Some(InputContentType::MiddleName), Role::TextInput),
873            (Some(InputContentType::FamilyName), Role::TextInput),
874            (Some(InputContentType::NameSuffix), Role::TextInput),
875            (Some(InputContentType::Nickname), Role::TextInput),
876            (Some(InputContentType::JobTitle), Role::TextInput),
877            (Some(InputContentType::OrganizationName), Role::TextInput),
878            (Some(InputContentType::Location), Role::TextInput),
879            (Some(InputContentType::FullStreetAddress), Role::TextInput),
880            (Some(InputContentType::StreetAddressLine1), Role::TextInput),
881            (Some(InputContentType::StreetAddressLine2), Role::TextInput),
882            (Some(InputContentType::AddressCity), Role::TextInput),
883            (Some(InputContentType::AddressState), Role::TextInput),
884            (Some(InputContentType::AddressCityAndState), Role::TextInput),
885            (Some(InputContentType::Sublocality), Role::TextInput),
886            (Some(InputContentType::CountryName), Role::TextInput),
887            (Some(InputContentType::PostalCode), Role::TextInput),
888            (
889                Some(InputContentType::TelephoneNumber),
890                Role::PhoneNumberInput,
891            ),
892            (Some(InputContentType::EmailAddress), Role::EmailInput),
893            (Some(InputContentType::Url), Role::UrlInput),
894            (Some(InputContentType::CreditCardNumber), Role::TextInput),
895            (Some(InputContentType::CreditCardName), Role::TextInput),
896            (Some(InputContentType::CreditCardGivenName), Role::TextInput),
897            (
898                Some(InputContentType::CreditCardMiddleName),
899                Role::TextInput,
900            ),
901            (
902                Some(InputContentType::CreditCardFamilyName),
903                Role::TextInput,
904            ),
905            (
906                Some(InputContentType::CreditCardSecurityCode),
907                Role::TextInput,
908            ),
909            (
910                Some(InputContentType::CreditCardExpiration),
911                Role::TextInput,
912            ),
913            (
914                Some(InputContentType::CreditCardExpirationMonth),
915                Role::TextInput,
916            ),
917            (
918                Some(InputContentType::CreditCardExpirationYear),
919                Role::TextInput,
920            ),
921            (Some(InputContentType::CreditCardType), Role::TextInput),
922            (Some(InputContentType::Username), Role::TextInput),
923            (Some(InputContentType::Password), Role::PasswordInput),
924            (Some(InputContentType::NewPassword), Role::PasswordInput),
925            (Some(InputContentType::OneTimeCode), Role::TextInput),
926            (
927                Some(InputContentType::ShipmentTrackingNumber),
928                Role::TextInput,
929            ),
930            (Some(InputContentType::FlightNumber), Role::TextInput),
931            (Some(InputContentType::DateTime), Role::DateTimeInput),
932            (Some(InputContentType::Birthdate), Role::DateInput),
933            (Some(InputContentType::BirthdateDay), Role::TextInput),
934            (Some(InputContentType::BirthdateMonth), Role::TextInput),
935            (Some(InputContentType::BirthdateYear), Role::TextInput),
936            (Some(InputContentType::CellularEid), Role::TextInput),
937            (Some(InputContentType::CellularImei), Role::TextInput),
938        ];
939
940        for (content_type, role) in cases {
941            assert_eq!(
942                accessibility_role(false, content_type, RoleOverride::Implicit),
943                Some(role)
944            );
945        }
946    }
947
948    #[test]
949    fn multiline_inputs_keep_multiline_accessibility_role() {
950        assert_eq!(
951            accessibility_role(
952                true,
953                Some(InputContentType::Password),
954                RoleOverride::Implicit
955            ),
956            Some(Role::MultilineTextInput)
957        );
958    }
959
960    #[test]
961    fn explicit_accessibility_role_overrides_defaults() {
962        assert_eq!(
963            accessibility_role(
964                false,
965                Some(InputContentType::Password),
966                Role::TextInput.into()
967            ),
968            Some(Role::TextInput)
969        );
970        assert_eq!(
971            accessibility_role(
972                true,
973                Some(InputContentType::Password),
974                Role::TextInput.into()
975            ),
976            Some(Role::TextInput)
977        );
978    }
979
980    #[test]
981    fn presentational_role_emits_no_accessibility_node() {
982        assert_eq!(
983            accessibility_role(
984                false,
985                Some(InputContentType::Password),
986                RoleOverride::Presentational
987            ),
988            None
989        );
990        assert_eq!(
991            accessibility_role(true, None, RoleOverride::Presentational),
992            None
993        );
994    }
995
996    #[test]
997    fn role_option_converts_to_the_matching_override() {
998        assert_eq!(
999            RoleOverride::from(Some(Role::Button)),
1000            RoleOverride::Role(Role::Button)
1001        );
1002        assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
1003    }
1004
1005    #[gpui::test]
1006    fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
1007        use gpui::{AppContext as _, Render};
1008
1009        struct Probe;
1010        impl Render for Probe {
1011            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1012                div()
1013            }
1014        }
1015
1016        cx.update(crate::init);
1017        let _ = cx.add_window_view(|window, cx| {
1018            let state = cx.new(|cx| InputState::new(window, cx));
1019
1020            assert!(Input::new(&state).paste_handler.is_none());
1021            let input = Input::new(&state).on_paste(|_, _, _| true);
1022            assert!(input.paste_handler.is_some());
1023            Probe
1024        });
1025    }
1026
1027    #[gpui::test]
1028    fn test_on_token_hover_builder(cx: &mut gpui::TestAppContext) {
1029        use gpui::{AppContext as _, Render};
1030
1031        struct Probe;
1032        impl Render for Probe {
1033            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1034                div()
1035            }
1036        }
1037
1038        cx.update(crate::init);
1039        let _ = cx.add_window_view(|window, cx| {
1040            let state = cx.new(|cx| InputState::new(window, cx));
1041
1042            assert!(Input::new(&state).token_hover_listener.is_none());
1043            let input = Input::new(&state).on_token_hover(|_, _, _| {});
1044            assert!(input.token_hover_listener.is_some());
1045            Probe
1046        });
1047    }
1048
1049    #[gpui::test]
1050    fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
1051        use crate::ElementExt as _;
1052        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1053        use std::sync::{Arc, Mutex};
1054
1055        type EmittedState = Option<(Option<String>, bool)>;
1056
1057        struct InputA11yProbe {
1058            state: Entity<InputState>,
1059            emitted: Arc<Mutex<EmittedState>>,
1060        }
1061
1062        impl Render for InputA11yProbe {
1063            fn render(
1064                &mut self,
1065                _window: &mut Window,
1066                _cx: &mut gpui::Context<Self>,
1067            ) -> impl IntoElement {
1068                let state = self.state.clone();
1069                let emitted = self.emitted.clone();
1070                div()
1071                    .child(Input::new(&state))
1072                    .on_prepaint(move |_, window, cx| {
1073                        let input = Input::new(&state).render(window, cx).into_element();
1074                        let mut node = gpui::accesskit::Node::new(Role::TextInput);
1075                        input.write_a11y_info(&mut node);
1076                        *emitted.lock().unwrap() = Some((
1077                            node.value().map(ToOwned::to_owned),
1078                            node.supports_action(AccessibleAction::SetValue),
1079                        ));
1080                    })
1081            }
1082        }
1083
1084        cx.update(crate::init);
1085        let emitted = Arc::new(Mutex::new(None));
1086        let captured = emitted.clone();
1087        let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1088            state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
1089            emitted,
1090        });
1091        cx.update(|window, cx| {
1092            let _ = window.draw(cx);
1093        });
1094        // Normal builds leave the value lazy without an accessibility client;
1095        // test-support reads that same production value path eagerly.
1096        let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
1097        assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
1098
1099        let state = probe.read_with(cx, |probe, _| probe.state.clone());
1100        let base: TextInputState = state.clone().into();
1101        cx.update(|window, cx| {
1102            Input::handle_accessibility_set_value(&base, None, window, cx);
1103            Input::handle_accessibility_focus(&base, window, cx);
1104            assert!(base.presentation(cx).focus_handle().is_focused(window));
1105            window.draw(cx).clear(cx);
1106        });
1107        assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1108
1109        let changes = std::rc::Rc::new(std::cell::Cell::new(0));
1110        let observed = changes.clone();
1111        let _subscription = cx.update(|_, cx| {
1112            cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| {
1113                if matches!(event, super::super::InputEvent::Change) {
1114                    observed.set(observed.get() + 1);
1115                }
1116            })
1117        });
1118        let action = gpui::accesskit::ActionData::Value("updated🦀".into());
1119        cx.update(|window, cx| {
1120            Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1121        });
1122        assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1123        assert_eq!(changes.get(), 1);
1124        for disabled in [false, true] {
1125            cx.update(|window, cx| {
1126                base.set_disabled(disabled, cx);
1127                base.set_readonly(!disabled, cx);
1128                window.blur(cx);
1129                Input::handle_accessibility_focus(&base, window, cx);
1130                assert_eq!(
1131                    base.presentation(cx).focus_handle().is_focused(window),
1132                    !disabled
1133                );
1134                let action = gpui::accesskit::ActionData::Value("rejected".into());
1135                Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1136            });
1137            assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1138            assert_eq!(changes.get(), 1);
1139        }
1140        cx.update(|window, cx| {
1141            base.set_disabled(false, cx);
1142            base.set_readonly(false, cx);
1143            Input::handle_accessibility_focus(&base, window, cx);
1144            window.draw(cx).clear(cx);
1145            window.dispatch_action(Box::new(super::super::Undo), cx);
1146        });
1147        assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1148        cx.update(|window, cx| {
1149            state.update(cx, |state, cx| state.set_masked(true, window, cx));
1150            Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1151            window.draw(cx).clear(cx);
1152        });
1153        assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1154        assert_eq!(*captured.lock().unwrap(), Some((None, true)));
1155    }
1156
1157    #[gpui::test]
1158    fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) {
1159        use gpui::{AppContext as _, Render};
1160
1161        struct Probe(Entity<crate::input::EditorState>);
1162
1163        impl Render for Probe {
1164            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1165                div().child(crate::input::Editor::new(&self.0))
1166            }
1167        }
1168
1169        cx.update(crate::init);
1170        let (probe, cx) = cx.add_window_view(|window, cx| {
1171            Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust")))
1172        });
1173        let editor = probe.read_with(cx, |probe, _| probe.0.clone());
1174        let state: TextInputState = editor.clone().into();
1175        let action = gpui::accesskit::ActionData::Value("(".into());
1176
1177        cx.update(|window, cx| {
1178            Input::handle_accessibility_set_value(&state, Some(&action), window, cx)
1179        });
1180        assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "(");
1181    }
1182
1183    #[gpui::test]
1184    fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
1185        use crate::ElementExt as _;
1186        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1187        use std::sync::{Arc, Mutex};
1188
1189        type EmittedIds = Vec<Option<String>>;
1190
1191        struct InputA11yProbe {
1192            state: Entity<InputState>,
1193            emitted: Arc<Mutex<EmittedIds>>,
1194        }
1195
1196        impl Render for InputA11yProbe {
1197            fn render(
1198                &mut self,
1199                _window: &mut Window,
1200                _cx: &mut gpui::Context<Self>,
1201            ) -> impl IntoElement {
1202                let state = self.state.clone();
1203                let emitted = self.emitted.clone();
1204                div().on_prepaint(move |_, window, cx| {
1205                    let mut author_id_of = |input: Input| {
1206                        let mut node = gpui::accesskit::Node::new(Role::TextInput);
1207                        input
1208                            .render(window, cx)
1209                            .into_element()
1210                            .write_a11y_info(&mut node);
1211                        node.author_id().map(ToOwned::to_owned)
1212                    };
1213
1214                    *emitted.lock().unwrap() = vec![
1215                        author_id_of(Input::new(&state)),
1216                        author_id_of(Input::new(&state).accessibility_id("search.query")),
1217                    ];
1218                })
1219            }
1220        }
1221
1222        cx.update(crate::init);
1223        let emitted = Arc::new(Mutex::new(Vec::new()));
1224        let captured = emitted.clone();
1225        let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1226            state: cx.new(|cx| InputState::new(window, cx)),
1227            emitted,
1228        });
1229        cx.update(|window, cx| {
1230            let _ = window.draw(cx);
1231        });
1232
1233        assert_eq!(
1234            *captured.lock().unwrap(),
1235            vec![None, Some("search.query".into())]
1236        );
1237    }
1238
1239    #[test]
1240    fn accessibility_value_is_hidden_for_secret_inputs() {
1241        assert!(exposes_accessibility_value(false, None));
1242        assert!(!exposes_accessibility_value(true, None));
1243        assert!(!exposes_accessibility_value(
1244            false,
1245            Some(InputContentType::Password)
1246        ));
1247        assert!(!exposes_accessibility_value(
1248            false,
1249            Some(InputContentType::NewPassword)
1250        ));
1251    }
1252
1253    #[gpui::test]
1254    fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1255        use crate::{Root, WindowExt as _};
1256        use gpui::{AppContext as _, Render};
1257
1258        struct Probe {
1259            input: Entity<InputState>,
1260            textarea: Entity<crate::input::TextareaState>,
1261            editor: Entity<crate::input::EditorState>,
1262            otp: Entity<gpui_base::OtpState>,
1263            other: gpui::FocusHandle,
1264        }
1265        impl Render for Probe {
1266            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1267                div()
1268                    .child(div().track_focus(&self.other))
1269                    .child(Input::new(&self.input))
1270                    .child(crate::input::Textarea::new(&self.textarea))
1271                    .child(crate::input::Editor::new(&self.editor))
1272                    .child(crate::input::OtpInput::new(&self.otp))
1273            }
1274        }
1275
1276        cx.update(crate::init);
1277        let mut input = None;
1278        let mut textarea = None;
1279        let mut editor = None;
1280        let mut other_focus = None;
1281        let mut otp = None;
1282        let window = cx.update(|cx| {
1283            cx.open_window(Default::default(), |window, cx| {
1284                let state = cx.new(|cx| InputState::new(window, cx));
1285                let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1286                let editor_state =
1287                    cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1288                let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1289                input = Some(state.clone());
1290                textarea = Some(textarea_state.clone());
1291                editor = Some(editor_state.clone());
1292                otp = Some(otp_state.clone());
1293                let other = cx.focus_handle();
1294                other_focus = Some(other.clone());
1295                let probe = cx.new(|_| Probe {
1296                    input: state,
1297                    textarea: textarea_state,
1298                    editor: editor_state,
1299                    otp: otp_state,
1300                    other,
1301                });
1302                cx.new(|cx| Root::new(probe, window, cx))
1303            })
1304            .unwrap()
1305        });
1306        let input = input.unwrap();
1307        let textarea = textarea.unwrap();
1308        let editor = editor.unwrap();
1309        let otp = otp.unwrap();
1310        let other_focus = other_focus.unwrap();
1311        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1312
1313        // Focusing each kind of input registers it, and blurring clears it.
1314        let cases: Vec<AnyInputState> = vec![
1315            input.clone().into(),
1316            textarea.clone().into(),
1317            editor.clone().into(),
1318            otp.clone().into(),
1319        ];
1320        for expected in cases {
1321            cx.update(|window, cx| {
1322                let _ = window.draw(cx);
1323            });
1324            cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1325            cx.run_until_parked();
1326            cx.update(|window, cx| {
1327                let _ = window.draw(cx);
1328            });
1329            assert_eq!(
1330                cx.update(|window, cx| window.focused_input(cx)),
1331                Some(expected)
1332            );
1333
1334            cx.update(|window, cx| other_focus.clone().focus(window, cx));
1335            cx.run_until_parked();
1336            cx.update(|window, cx| {
1337                let _ = window.draw(cx);
1338            });
1339            assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1340        }
1341    }
1342
1343    #[gpui::test]
1344    fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1345        use crate::{Root, WindowExt as _};
1346        use gpui::{AppContext as _, Render};
1347
1348        struct Probe {
1349            input: Entity<InputState>,
1350            show_input: bool,
1351            other: gpui::FocusHandle,
1352        }
1353        impl Render for Probe {
1354            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1355                let base = div().child(div().track_focus(&self.other));
1356                if self.show_input {
1357                    base.child(Input::new(&self.input))
1358                } else {
1359                    base
1360                }
1361            }
1362        }
1363
1364        cx.update(crate::init);
1365        let mut input = None;
1366        let mut other_focus = None;
1367        let mut probe_entity = None;
1368        let window = cx.update(|cx| {
1369            cx.open_window(Default::default(), |window, cx| {
1370                let state = cx.new(|cx| InputState::new(window, cx));
1371                input = Some(state.clone());
1372                let other = cx.focus_handle();
1373                other_focus = Some(other.clone());
1374                let probe = cx.new(|_| Probe {
1375                    input: state,
1376                    show_input: true,
1377                    other,
1378                });
1379                probe_entity = Some(probe.clone());
1380                cx.new(|cx| Root::new(probe, window, cx))
1381            })
1382            .unwrap()
1383        });
1384        let input: AnyInputState = input.unwrap().into();
1385        let other_focus = other_focus.unwrap();
1386        let probe = probe_entity.unwrap();
1387        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1388
1389        cx.update(|window, cx| {
1390            let _ = window.draw(cx);
1391        });
1392        cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1393        cx.run_until_parked();
1394        cx.update(|window, cx| {
1395            let _ = window.draw(cx);
1396        });
1397        assert_eq!(
1398            cx.update(|window, cx| window.focused_input(cx)),
1399            Some(input.clone())
1400        );
1401
1402        // Remove the input from the tree while it holds focus and move focus
1403        // elsewhere in the same update (e.g. closing a sidebar containing the
1404        // input) — the input never re-renders to unregister itself.
1405        cx.update(|window, cx| {
1406            probe.update(cx, |probe, cx| {
1407                probe.show_input = false;
1408                cx.notify();
1409            });
1410            other_focus.focus(window, cx);
1411        });
1412        cx.run_until_parked();
1413        cx.update(|window, cx| {
1414            let _ = window.draw(cx);
1415        });
1416        assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1417        assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1418    }
1419}