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
97pub(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#[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 context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
140
141 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 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 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 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
208 self.id = Some(id.into());
209 self
210 }
211
212 pub fn new(state: &Entity<InputState>) -> Self {
214 Self::with_state(state.clone().into())
215 }
216
217 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 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 pub fn h_full(mut self) -> Self {
288 self.height = Some(relative(1.));
289 self
290 }
291
292 pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
294 self.height = Some(height.into());
295 self
296 }
297
298 pub fn appearance(mut self, appearance: bool) -> Self {
300 self.appearance = appearance;
301 self
302 }
303
304 pub fn bordered(mut self, bordered: bool) -> Self {
306 self.bordered = bordered;
307 self
308 }
309
310 pub fn focus_bordered(mut self, bordered: bool) -> Self {
312 self.focus_bordered = bordered;
313 self
314 }
315
316 pub fn cleanable(mut self, cleanable: bool) -> Self {
318 self.cleanable = cleanable;
319 self
320 }
321
322 pub fn mask_toggle(mut self) -> Self {
324 self.mask_toggle = true;
325 self
326 }
327
328 pub fn content_type(mut self, content_type: InputContentType) -> Self {
333 self.content_type = Some(content_type);
334 self
335 }
336
337 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
341 self.role = role.into();
342 self
343 }
344
345 pub fn disabled(mut self, disabled: bool) -> Self {
347 self.disabled = disabled;
348 self
349 }
350
351 pub fn readonly(mut self, readonly: bool) -> Self {
357 self.readonly = readonly;
358 self
359 }
360
361 pub fn tab_index(mut self, index: isize) -> Self {
363 self.tab_index = index;
364 self
365 }
366
367 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 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 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 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 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 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 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 .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 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 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 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 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 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 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 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 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}