Skip to main content

gpui_component/input/
input.rs

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