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            .accessibility_label(if masked {
485                t!("Input.Show Password")
486            } else {
487                t!("Input.Hide Password")
488            })
489            .xsmall()
490            .text()
491            .tab_stop(false)
492            .on_click({
493                let state = state.clone();
494                move |_, window, cx| state.toggle_masked(window, cx)
495            })
496    }
497
498    fn handle_accessibility_set_value(
499        state: &TextInputState,
500        data: Option<&gpui::accesskit::ActionData>,
501        window: &mut Window,
502        cx: &mut App,
503    ) {
504        let Some(gpui::accesskit::ActionData::Value(value)) = data else {
505            return;
506        };
507        if !state.presentation(cx).is_editable() {
508            return;
509        }
510        state.replace_all(value.to_string(), window, cx);
511    }
512
513    fn handle_accessibility_focus(state: &TextInputState, window: &mut Window, cx: &mut App) {
514        if !state.presentation(cx).is_disabled() {
515            state.focus(window, cx);
516        }
517    }
518
519    /// This method must after the refine_style.
520    fn render_editor(
521        input_state: TextInputState,
522        search_panel: Option<AnyElement>,
523        _: &Window,
524    ) -> impl IntoElement {
525        v_flex().size_full().children(search_panel).child(
526            div()
527                .relative()
528                .flex_1()
529                .child(input_state.into_any_element()),
530        )
531    }
532}
533
534impl Styled for Input {
535    fn style(&mut self) -> &mut StyleRefinement {
536        &mut self.style
537    }
538}
539
540impl RenderOnce for Input {
541    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
542        const LINE_HEIGHT: Rems = Rems(1.25);
543        let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
544        let state = self.state.clone();
545        state.install_token_presentation(
546            Some(self.token_renderer.unwrap_or_else(|| {
547                Rc::new(|token, _, _| super::InputToken::new(token).into_any_element())
548            })),
549            self.token_click_listener,
550            matches!(
551                self.content_type,
552                Some(InputContentType::Password | InputContentType::NewPassword)
553            ),
554            cx,
555        );
556        state.install_token_hover_presentation(self.token_hover_listener, cx);
557        // Which kind of input this registers as follows from the state itself.
558        sync_focused_input_registry(&state, window, cx);
559
560        state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx);
561        state.set_editor_style(
562            gpui_base::input::InputEditorStyle {
563                foreground: cx.theme().foreground,
564                muted_foreground: cx.theme().muted_foreground,
565                background: cx.theme().editor_background(),
566                border: cx.theme().border,
567                selection: cx.theme().selection,
568                caret: cx.theme().caret,
569                diagnostics: gpui_base::input::DiagnosticColors {
570                    error: cx.theme().highlight_theme.style.status.error(cx),
571                    warning: cx.theme().highlight_theme.style.status.warning(cx),
572                    info: cx.theme().highlight_theme.style.status.info(cx),
573                    hint: cx.theme().highlight_theme.style.status.hint(cx),
574                },
575                highlight_styles: cx.theme().highlight_theme.clone(),
576                editor_invisible: cx.theme().highlight_theme.style.editor_invisible,
577                editor_active_line: cx.theme().highlight_theme.style.editor_active_line,
578                editor_gutter_background: cx.theme().highlight_theme.style.editor_gutter_background,
579                fold_icon_renderer: Some(Rc::new(|ix, is_folded| {
580                    Button::new(("fold-icon", ix))
581                        .ghost()
582                        .icon(if is_folded {
583                            IconName::ChevronRight
584                        } else {
585                            IconName::ChevronDown
586                        })
587                        .xsmall()
588                        .rounded(ButtonRounded::Small)
589                        .size(px(14.))
590                        .selected(is_folded)
591                        .into_any_element()
592                })),
593            },
594            cx,
595        );
596        state.set_editor_paddings(
597            if state.presentation(cx).is_multi_line() {
598                Edges {
599                    top: self.size.input_py(),
600                    right: self.size.input_px(),
601                    bottom: self.size.input_py(),
602                    left: if state.presentation(cx).is_code_editor() {
603                        self.size.input_px().min(px(6.))
604                    } else {
605                        self.size.input_px()
606                    },
607                }
608            } else {
609                Edges::default()
610            },
611            cx,
612        );
613        state.set_disabled(self.disabled, cx);
614        state.set_readonly(self.readonly, cx);
615        state.set_text_align(text_align, cx);
616        let custom = self.context_menu_builder.clone();
617        state.on_context_menu(
618            Rc::new(move |_, capabilities, position, window, cx| {
619                let menu = if let Some(custom) = custom.as_ref() {
620                    custom(NativeMenu::new(), window, cx)
621                } else {
622                    let enabled = !capabilities.is_disabled();
623                    // A read-only input can still navigate the code, it only
624                    // rejects the items that would change the text.
625                    let editable = enabled && !capabilities.is_readonly();
626                    let mut menu = NativeMenu::new();
627                    if capabilities.is_code_editor() {
628                        menu = menu
629                            .menu_with_disabled(
630                                t!("Input.Go to Definition"),
631                                !(enabled && capabilities.has_definition()),
632                                Box::new(gpui_base::input::GoToDefinition),
633                            )
634                            .menu_with_disabled(
635                                t!("Input.Show Code Actions"),
636                                !(editable && capabilities.has_code_actions()),
637                                Box::new(gpui_base::input::ToggleCodeActions),
638                            )
639                            .separator();
640                    }
641                    menu.menu_with_disabled(
642                        t!("Input.Cut"),
643                        !(editable && capabilities.is_copyable()),
644                        Box::new(gpui_base::input::Cut),
645                    )
646                    .menu_with_disabled(
647                        t!("Input.Copy"),
648                        !capabilities.is_copyable(),
649                        Box::new(gpui_base::input::Copy),
650                    )
651                    // Offered whenever the text can change, without peeking
652                    // at the clipboard: the synchronous read is always empty
653                    // on the web, and an empty clipboard pastes nothing.
654                    .menu_with_disabled(
655                        t!("Input.Paste"),
656                        !editable,
657                        Box::new(gpui_base::input::Paste),
658                    )
659                    .separator()
660                    .menu(
661                        t!("Input.Select All"),
662                        Box::new(gpui_base::input::SelectAll),
663                    )
664                };
665                menu.show(position, window, cx);
666            }),
667            cx,
668        );
669        // The engine ignores `Paste` on a read-only or disabled input; the hook
670        // must not see a paste the input itself would refuse.
671        let paste_handler = self
672            .paste_handler
673            .clone()
674            .filter(|_| state.presentation(cx).is_editable());
675        let mut overlays = state.render_overlays(window, cx);
676        overlays
677            .floating
678            .extend(Self::render_touch_selection(&state, window, cx));
679
680        let presentation = state.presentation(cx);
681        let content_type = self.content_type;
682        let disabled = self.disabled;
683        let is_multi_line = presentation.is_multi_line();
684        // Without a content type, a masked input is a password field to screen
685        // readers, so they announce it as one.
686        let accessibility_role = accessibility_role(
687            is_multi_line,
688            content_type.or(presentation
689                .is_masked()
690                .then_some(InputContentType::Password)),
691            self.role,
692        );
693        let accessibility_state = state.clone();
694        // Tests read the same accessibility value as assistive technology.
695        // Avoid materializing the rope in normal builds without a client.
696        let accessibility_value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
697            && exposes_accessibility_value(presentation.is_masked(), content_type))
698        .then(|| state.text(cx).to_string());
699        let input_focused =
700            presentation.focus_handle().is_focused(window) && !presentation.is_disabled();
701        if input_focused {
702            sync_native_content_type(window, content_type, presentation.is_editable());
703        }
704        let frame_focus_handle = window
705            .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| {
706                cx.focus_handle()
707            })
708            .read(cx)
709            .clone();
710        let focused = !presentation.is_disabled()
711            && (state.has_selection_focus(window, cx)
712                || frame_focus_handle.contains_focused(window, cx));
713
714        let gap_x = match self.size {
715            Size::Small => px(4.),
716            Size::Large => px(8.),
717            _ => px(6.),
718        };
719
720        let (bg, _) = input_style(presentation.is_disabled(), cx);
721        let bg = if presentation.is_code_editor() {
722            cx.theme().editor_background()
723        } else {
724            bg
725        };
726        let bg = if presentation.is_disabled() {
727            bg.opacity(0.5)
728        } else {
729            bg
730        };
731        let prefix = self.prefix;
732        let suffix = self.suffix;
733        let show_clear_button = self.cleanable
734            && presentation.is_editable()
735            && !presentation.is_loading()
736            && state.text(cx).len() > 0
737            && !presentation.is_multi_line();
738        let has_suffix =
739            suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
740
741        let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
742
743        // Don't use a mask-derived placeholder ("(___)___-___") as an aria_label fallback.
744        let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
745
746        let aria_label = match self.aria_label {
747            Some(label) => Some(label),
748            None if placeholder_is_mask => None,
749            None => placeholder.clone(),
750        };
751        let id = self
752            .id
753            .unwrap_or_else(|| ("input", state.entity_id()).into());
754        BaseInput::new(id)
755            .focused(focused)
756            .disabled(disabled)
757            .track_focus(&frame_focus_handle)
758            .on_mouse_down(MouseButton::Right, {
759                let state = state.clone();
760                move |_, window, cx| {
761                    // The text element has already taken focus. Publish its state
762                    // before an outer ContextMenu resolves the selection owner;
763                    // render-time registration still names the previous input.
764                    sync_focused_input_registry(&state, window, cx);
765                }
766            })
767            .when(disabled, |this| {
768                this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
769            })
770            .styles(|styles| {
771                styles.focused(|style| {
772                    style.when(
773                        self.appearance && self.bordered && self.focus_bordered,
774                        |style| style.border_1().border_color(cx.theme().ring),
775                    )
776                })
777            })
778            .role(accessibility_role)
779            .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
780            .when_some(aria_label, |this, label| this.aria_label(label))
781            .when_some(placeholder, |this, placeholder| {
782                this.aria_placeholder(placeholder)
783            })
784            .when_some(accessibility_value, |this, value| this.aria_value(value))
785            .on_a11y_action(AccessibleAction::Focus, {
786                let state = state.clone();
787                move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx)
788            })
789            .when(presentation.is_editable(), |this| {
790                this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
791                    Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
792                })
793            })
794            .flex()
795            .size_full()
796            .line_height(LINE_HEIGHT)
797            .when(!is_multi_line, |this| {
798                this.input_px(self.size).input_py(self.size)
799            })
800            .input_h(self.size)
801            .input_text_size(self.size)
802            .items_center()
803            .when(presentation.is_multi_line(), |this| {
804                this.h_auto()
805                    .when_some(self.height, |this, height| this.h(height))
806            })
807            .when(self.appearance, |this| {
808                this.bg(bg)
809                    .rounded(cx.theme().radius)
810                    .when(self.bordered, |this| {
811                        this.border_1().border_color(cx.theme().input)
812                    })
813            })
814            .items_center()
815            .gap(gap_x)
816            .refine_style(&self.style)
817            .when(
818                focused && self.appearance && self.bordered && self.focus_bordered,
819                |this| this.focus_ring_style(window, cx),
820            )
821            .children(prefix.map(|p| {
822                div()
823                    .when(presentation.is_disabled(), |this| this.opacity(0.5))
824                    .child(p)
825            }))
826            .when(presentation.is_multi_line(), |this| {
827                this.child(Self::render_editor(state.clone(), overlays.search, window))
828            })
829            .when(!presentation.is_multi_line(), |this| {
830                this.child(state.clone().into_any_element())
831            })
832            .when(has_suffix, |this| {
833                this.pr(self.size.input_px()).child(
834                    h_flex()
835                        .id("suffix")
836                        .gap(gap_x)
837                        .items_center()
838                        .cursor_default()
839                        .when(presentation.is_disabled(), |this| this.opacity(0.5))
840                        .when(presentation.is_loading(), |this| {
841                            this.child(Spinner::new().color(cx.theme().muted_foreground))
842                        })
843                        .when(self.mask_toggle, |this| {
844                            this.child(Self::render_toggle_mask_button(&state, cx))
845                        })
846                        .when(show_clear_button, |this| {
847                            this.child(clear_button(cx).on_click({
848                                let state = state.clone();
849                                move |_, window, cx| {
850                                    state.clean(window, cx);
851                                    state.focus(window, cx);
852                                }
853                            }))
854                        })
855                        .children(suffix),
856                )
857            })
858            .relative()
859            .children(overlays.floating)
860            .when_some(paste_handler, |this, handler| {
861                this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
862                    if let Some(clipboard) = cx.read_from_clipboard() {
863                        if handler(&clipboard, window, cx) {
864                            cx.stop_propagation();
865                        }
866                    }
867                })
868            })
869            .render(window, cx)
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use crate::input::AnyInputState;
877
878    #[test]
879    fn content_types_map_to_accessibility_roles() {
880        let cases = [
881            (None, Role::TextInput),
882            (Some(InputContentType::Name), Role::TextInput),
883            (Some(InputContentType::NamePrefix), Role::TextInput),
884            (Some(InputContentType::GivenName), Role::TextInput),
885            (Some(InputContentType::MiddleName), Role::TextInput),
886            (Some(InputContentType::FamilyName), Role::TextInput),
887            (Some(InputContentType::NameSuffix), Role::TextInput),
888            (Some(InputContentType::Nickname), Role::TextInput),
889            (Some(InputContentType::JobTitle), Role::TextInput),
890            (Some(InputContentType::OrganizationName), Role::TextInput),
891            (Some(InputContentType::Location), Role::TextInput),
892            (Some(InputContentType::FullStreetAddress), Role::TextInput),
893            (Some(InputContentType::StreetAddressLine1), Role::TextInput),
894            (Some(InputContentType::StreetAddressLine2), Role::TextInput),
895            (Some(InputContentType::AddressCity), Role::TextInput),
896            (Some(InputContentType::AddressState), Role::TextInput),
897            (Some(InputContentType::AddressCityAndState), Role::TextInput),
898            (Some(InputContentType::Sublocality), Role::TextInput),
899            (Some(InputContentType::CountryName), Role::TextInput),
900            (Some(InputContentType::PostalCode), Role::TextInput),
901            (
902                Some(InputContentType::TelephoneNumber),
903                Role::PhoneNumberInput,
904            ),
905            (Some(InputContentType::EmailAddress), Role::EmailInput),
906            (Some(InputContentType::Url), Role::UrlInput),
907            (Some(InputContentType::CreditCardNumber), Role::TextInput),
908            (Some(InputContentType::CreditCardName), Role::TextInput),
909            (Some(InputContentType::CreditCardGivenName), Role::TextInput),
910            (
911                Some(InputContentType::CreditCardMiddleName),
912                Role::TextInput,
913            ),
914            (
915                Some(InputContentType::CreditCardFamilyName),
916                Role::TextInput,
917            ),
918            (
919                Some(InputContentType::CreditCardSecurityCode),
920                Role::TextInput,
921            ),
922            (
923                Some(InputContentType::CreditCardExpiration),
924                Role::TextInput,
925            ),
926            (
927                Some(InputContentType::CreditCardExpirationMonth),
928                Role::TextInput,
929            ),
930            (
931                Some(InputContentType::CreditCardExpirationYear),
932                Role::TextInput,
933            ),
934            (Some(InputContentType::CreditCardType), Role::TextInput),
935            (Some(InputContentType::Username), Role::TextInput),
936            (Some(InputContentType::Password), Role::PasswordInput),
937            (Some(InputContentType::NewPassword), Role::PasswordInput),
938            (Some(InputContentType::OneTimeCode), Role::TextInput),
939            (
940                Some(InputContentType::ShipmentTrackingNumber),
941                Role::TextInput,
942            ),
943            (Some(InputContentType::FlightNumber), Role::TextInput),
944            (Some(InputContentType::DateTime), Role::DateTimeInput),
945            (Some(InputContentType::Birthdate), Role::DateInput),
946            (Some(InputContentType::BirthdateDay), Role::TextInput),
947            (Some(InputContentType::BirthdateMonth), Role::TextInput),
948            (Some(InputContentType::BirthdateYear), Role::TextInput),
949            (Some(InputContentType::CellularEid), Role::TextInput),
950            (Some(InputContentType::CellularImei), Role::TextInput),
951        ];
952
953        for (content_type, role) in cases {
954            assert_eq!(
955                accessibility_role(false, content_type, RoleOverride::Implicit),
956                Some(role)
957            );
958        }
959    }
960
961    #[test]
962    fn multiline_inputs_keep_multiline_accessibility_role() {
963        assert_eq!(
964            accessibility_role(
965                true,
966                Some(InputContentType::Password),
967                RoleOverride::Implicit
968            ),
969            Some(Role::MultilineTextInput)
970        );
971    }
972
973    #[test]
974    fn explicit_accessibility_role_overrides_defaults() {
975        assert_eq!(
976            accessibility_role(
977                false,
978                Some(InputContentType::Password),
979                Role::TextInput.into()
980            ),
981            Some(Role::TextInput)
982        );
983        assert_eq!(
984            accessibility_role(
985                true,
986                Some(InputContentType::Password),
987                Role::TextInput.into()
988            ),
989            Some(Role::TextInput)
990        );
991    }
992
993    #[test]
994    fn presentational_role_emits_no_accessibility_node() {
995        assert_eq!(
996            accessibility_role(
997                false,
998                Some(InputContentType::Password),
999                RoleOverride::Presentational
1000            ),
1001            None
1002        );
1003        assert_eq!(
1004            accessibility_role(true, None, RoleOverride::Presentational),
1005            None
1006        );
1007    }
1008
1009    #[test]
1010    fn role_option_converts_to_the_matching_override() {
1011        assert_eq!(
1012            RoleOverride::from(Some(Role::Button)),
1013            RoleOverride::Role(Role::Button)
1014        );
1015        assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
1016    }
1017
1018    #[gpui::test]
1019    fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
1020        use gpui::{AppContext as _, Render};
1021
1022        struct Probe;
1023        impl Render for Probe {
1024            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1025                div()
1026            }
1027        }
1028
1029        cx.update(crate::init);
1030        let _ = cx.add_window_view(|window, cx| {
1031            let state = cx.new(|cx| InputState::new(window, cx));
1032
1033            assert!(Input::new(&state).paste_handler.is_none());
1034            let input = Input::new(&state).on_paste(|_, _, _| true);
1035            assert!(input.paste_handler.is_some());
1036            Probe
1037        });
1038    }
1039
1040    #[gpui::test]
1041    fn test_on_token_hover_builder(cx: &mut gpui::TestAppContext) {
1042        use gpui::{AppContext as _, Render};
1043
1044        struct Probe;
1045        impl Render for Probe {
1046            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1047                div()
1048            }
1049        }
1050
1051        cx.update(crate::init);
1052        let _ = cx.add_window_view(|window, cx| {
1053            let state = cx.new(|cx| InputState::new(window, cx));
1054
1055            assert!(Input::new(&state).token_hover_listener.is_none());
1056            let input = Input::new(&state).on_token_hover(|_, _, _| {});
1057            assert!(input.token_hover_listener.is_some());
1058            Probe
1059        });
1060    }
1061
1062    #[gpui::test]
1063    fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
1064        use crate::ElementExt as _;
1065        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1066        use std::sync::{Arc, Mutex};
1067
1068        type EmittedState = Option<(Option<String>, bool)>;
1069
1070        struct InputA11yProbe {
1071            state: Entity<InputState>,
1072            emitted: Arc<Mutex<EmittedState>>,
1073        }
1074
1075        impl Render for InputA11yProbe {
1076            fn render(
1077                &mut self,
1078                _window: &mut Window,
1079                _cx: &mut gpui::Context<Self>,
1080            ) -> impl IntoElement {
1081                let state = self.state.clone();
1082                let emitted = self.emitted.clone();
1083                div()
1084                    .child(Input::new(&state))
1085                    .on_prepaint(move |_, window, cx| {
1086                        let input = Input::new(&state).render(window, cx).into_element();
1087                        let mut node = gpui::accesskit::Node::new(Role::TextInput);
1088                        input.write_a11y_info(&mut node);
1089                        *emitted.lock().unwrap() = Some((
1090                            node.value().map(ToOwned::to_owned),
1091                            node.supports_action(AccessibleAction::SetValue),
1092                        ));
1093                    })
1094            }
1095        }
1096
1097        cx.update(crate::init);
1098        let emitted = Arc::new(Mutex::new(None));
1099        let captured = emitted.clone();
1100        let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1101            state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
1102            emitted,
1103        });
1104        cx.update(|window, cx| {
1105            let _ = window.draw(cx);
1106        });
1107        // Normal builds leave the value lazy without an accessibility client;
1108        // test-support reads that same production value path eagerly.
1109        let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
1110        assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
1111
1112        let state = probe.read_with(cx, |probe, _| probe.state.clone());
1113        let base: TextInputState = state.clone().into();
1114        cx.update(|window, cx| {
1115            Input::handle_accessibility_set_value(&base, None, window, cx);
1116            Input::handle_accessibility_focus(&base, window, cx);
1117            assert!(base.presentation(cx).focus_handle().is_focused(window));
1118            window.draw(cx).clear(cx);
1119        });
1120        assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1121
1122        let changes = std::rc::Rc::new(std::cell::Cell::new(0));
1123        let observed = changes.clone();
1124        let _subscription = cx.update(|_, cx| {
1125            cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| {
1126                if matches!(event, super::super::InputEvent::Change) {
1127                    observed.set(observed.get() + 1);
1128                }
1129            })
1130        });
1131        let action = gpui::accesskit::ActionData::Value("updated🦀".into());
1132        cx.update(|window, cx| {
1133            Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1134        });
1135        assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1136        assert_eq!(changes.get(), 1);
1137        for disabled in [false, true] {
1138            cx.update(|window, cx| {
1139                base.set_disabled(disabled, cx);
1140                base.set_readonly(!disabled, cx);
1141                window.blur(cx);
1142                Input::handle_accessibility_focus(&base, window, cx);
1143                assert_eq!(
1144                    base.presentation(cx).focus_handle().is_focused(window),
1145                    !disabled
1146                );
1147                let action = gpui::accesskit::ActionData::Value("rejected".into());
1148                Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1149            });
1150            assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1151            assert_eq!(changes.get(), 1);
1152        }
1153        cx.update(|window, cx| {
1154            base.set_disabled(false, cx);
1155            base.set_readonly(false, cx);
1156            Input::handle_accessibility_focus(&base, window, cx);
1157            window.draw(cx).clear(cx);
1158            window.dispatch_action(Box::new(super::super::Undo), cx);
1159        });
1160        assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1161        cx.update(|window, cx| {
1162            state.update(cx, |state, cx| state.set_masked(true, window, cx));
1163            Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1164            window.draw(cx).clear(cx);
1165        });
1166        assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1167        assert_eq!(*captured.lock().unwrap(), Some((None, true)));
1168    }
1169
1170    #[gpui::test]
1171    fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) {
1172        use gpui::{AppContext as _, Render};
1173
1174        struct Probe(Entity<crate::input::EditorState>);
1175
1176        impl Render for Probe {
1177            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1178                div().child(crate::input::Editor::new(&self.0))
1179            }
1180        }
1181
1182        cx.update(crate::init);
1183        let (probe, cx) = cx.add_window_view(|window, cx| {
1184            Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust")))
1185        });
1186        let editor = probe.read_with(cx, |probe, _| probe.0.clone());
1187        let state: TextInputState = editor.clone().into();
1188        let action = gpui::accesskit::ActionData::Value("(".into());
1189
1190        cx.update(|window, cx| {
1191            Input::handle_accessibility_set_value(&state, Some(&action), window, cx)
1192        });
1193        assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "(");
1194    }
1195
1196    #[gpui::test]
1197    fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
1198        use crate::ElementExt as _;
1199        use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1200        use std::sync::{Arc, Mutex};
1201
1202        type EmittedIds = Vec<Option<String>>;
1203
1204        struct InputA11yProbe {
1205            state: Entity<InputState>,
1206            emitted: Arc<Mutex<EmittedIds>>,
1207        }
1208
1209        impl Render for InputA11yProbe {
1210            fn render(
1211                &mut self,
1212                _window: &mut Window,
1213                _cx: &mut gpui::Context<Self>,
1214            ) -> impl IntoElement {
1215                let state = self.state.clone();
1216                let emitted = self.emitted.clone();
1217                div().on_prepaint(move |_, window, cx| {
1218                    let mut author_id_of = |input: Input| {
1219                        let mut node = gpui::accesskit::Node::new(Role::TextInput);
1220                        input
1221                            .render(window, cx)
1222                            .into_element()
1223                            .write_a11y_info(&mut node);
1224                        node.author_id().map(ToOwned::to_owned)
1225                    };
1226
1227                    *emitted.lock().unwrap() = vec![
1228                        author_id_of(Input::new(&state)),
1229                        author_id_of(Input::new(&state).accessibility_id("search.query")),
1230                    ];
1231                })
1232            }
1233        }
1234
1235        cx.update(crate::init);
1236        let emitted = Arc::new(Mutex::new(Vec::new()));
1237        let captured = emitted.clone();
1238        let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1239            state: cx.new(|cx| InputState::new(window, cx)),
1240            emitted,
1241        });
1242        cx.update(|window, cx| {
1243            let _ = window.draw(cx);
1244        });
1245
1246        assert_eq!(
1247            *captured.lock().unwrap(),
1248            vec![None, Some("search.query".into())]
1249        );
1250    }
1251
1252    #[test]
1253    fn accessibility_value_is_hidden_for_secret_inputs() {
1254        assert!(exposes_accessibility_value(false, None));
1255        assert!(!exposes_accessibility_value(true, None));
1256        assert!(!exposes_accessibility_value(
1257            false,
1258            Some(InputContentType::Password)
1259        ));
1260        assert!(!exposes_accessibility_value(
1261            false,
1262            Some(InputContentType::NewPassword)
1263        ));
1264    }
1265
1266    #[gpui::test]
1267    fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1268        use crate::{Root, WindowExt as _};
1269        use gpui::{AppContext as _, Render};
1270
1271        struct Probe {
1272            input: Entity<InputState>,
1273            textarea: Entity<crate::input::TextareaState>,
1274            editor: Entity<crate::input::EditorState>,
1275            otp: Entity<gpui_base::OtpState>,
1276            other: gpui::FocusHandle,
1277        }
1278        impl Render for Probe {
1279            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1280                div()
1281                    .child(div().track_focus(&self.other))
1282                    .child(Input::new(&self.input))
1283                    .child(crate::input::Textarea::new(&self.textarea))
1284                    .child(crate::input::Editor::new(&self.editor))
1285                    .child(crate::input::OtpInput::new(&self.otp))
1286            }
1287        }
1288
1289        cx.update(crate::init);
1290        let mut input = None;
1291        let mut textarea = None;
1292        let mut editor = None;
1293        let mut other_focus = None;
1294        let mut otp = None;
1295        let window = cx.update(|cx| {
1296            cx.open_window(Default::default(), |window, cx| {
1297                let state = cx.new(|cx| InputState::new(window, cx));
1298                let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1299                let editor_state =
1300                    cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1301                let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1302                input = Some(state.clone());
1303                textarea = Some(textarea_state.clone());
1304                editor = Some(editor_state.clone());
1305                otp = Some(otp_state.clone());
1306                let other = cx.focus_handle();
1307                other_focus = Some(other.clone());
1308                let probe = cx.new(|_| Probe {
1309                    input: state,
1310                    textarea: textarea_state,
1311                    editor: editor_state,
1312                    otp: otp_state,
1313                    other,
1314                });
1315                cx.new(|cx| Root::new(probe, window, cx))
1316            })
1317            .unwrap()
1318        });
1319        let input = input.unwrap();
1320        let textarea = textarea.unwrap();
1321        let editor = editor.unwrap();
1322        let otp = otp.unwrap();
1323        let other_focus = other_focus.unwrap();
1324        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1325
1326        // Focusing each kind of input registers it, and blurring clears it.
1327        let cases: Vec<AnyInputState> = vec![
1328            input.clone().into(),
1329            textarea.clone().into(),
1330            editor.clone().into(),
1331            otp.clone().into(),
1332        ];
1333        for expected in cases {
1334            cx.update(|window, cx| {
1335                let _ = window.draw(cx);
1336            });
1337            cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1338            cx.run_until_parked();
1339            cx.update(|window, cx| {
1340                let _ = window.draw(cx);
1341            });
1342            assert_eq!(
1343                cx.update(|window, cx| window.focused_input(cx)),
1344                Some(expected)
1345            );
1346
1347            cx.update(|window, cx| other_focus.clone().focus(window, cx));
1348            cx.run_until_parked();
1349            cx.update(|window, cx| {
1350                let _ = window.draw(cx);
1351            });
1352            assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1353        }
1354    }
1355
1356    #[gpui::test]
1357    fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1358        use crate::{Root, WindowExt as _};
1359        use gpui::{AppContext as _, Render};
1360
1361        struct Probe {
1362            input: Entity<InputState>,
1363            show_input: bool,
1364            other: gpui::FocusHandle,
1365        }
1366        impl Render for Probe {
1367            fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1368                let base = div().child(div().track_focus(&self.other));
1369                if self.show_input {
1370                    base.child(Input::new(&self.input))
1371                } else {
1372                    base
1373                }
1374            }
1375        }
1376
1377        cx.update(crate::init);
1378        let mut input = None;
1379        let mut other_focus = None;
1380        let mut probe_entity = None;
1381        let window = cx.update(|cx| {
1382            cx.open_window(Default::default(), |window, cx| {
1383                let state = cx.new(|cx| InputState::new(window, cx));
1384                input = Some(state.clone());
1385                let other = cx.focus_handle();
1386                other_focus = Some(other.clone());
1387                let probe = cx.new(|_| Probe {
1388                    input: state,
1389                    show_input: true,
1390                    other,
1391                });
1392                probe_entity = Some(probe.clone());
1393                cx.new(|cx| Root::new(probe, window, cx))
1394            })
1395            .unwrap()
1396        });
1397        let input: AnyInputState = input.unwrap().into();
1398        let other_focus = other_focus.unwrap();
1399        let probe = probe_entity.unwrap();
1400        let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1401
1402        cx.update(|window, cx| {
1403            let _ = window.draw(cx);
1404        });
1405        cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1406        cx.run_until_parked();
1407        cx.update(|window, cx| {
1408            let _ = window.draw(cx);
1409        });
1410        assert_eq!(
1411            cx.update(|window, cx| window.focused_input(cx)),
1412            Some(input.clone())
1413        );
1414
1415        // Remove the input from the tree while it holds focus and move focus
1416        // elsewhere in the same update (e.g. closing a sidebar containing the
1417        // input) — the input never re-renders to unregister itself.
1418        cx.update(|window, cx| {
1419            probe.update(cx, |probe, cx| {
1420                probe.show_input = false;
1421                cx.notify();
1422            });
1423            other_focus.focus(window, cx);
1424        });
1425        cx.run_until_parked();
1426        cx.update(|window, cx| {
1427            let _ = window.draw(cx);
1428        });
1429        assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1430        assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1431    }
1432}