1use std::rc::Rc;
2
3use gpui::prelude::FluentBuilder as _;
4use gpui::{
5 AccessibleAction, AnyElement, App, DefiniteLength, Edges, ElementId, Entity, Hsla,
6 InteractiveElement as _, IntoElement, ParentElement as _, Rems, RenderOnce, Role, SharedString,
7 StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase, Window, div,
8 px, relative,
9};
10
11use crate::button::{Button, ButtonRounded, ButtonVariants as _};
12use crate::input::clear_button;
13use crate::native_menu::NativeMenu;
14use crate::spinner::Spinner;
15use crate::touch_selection::{EditMenuItem, TouchSelectionOverlay};
16use crate::{ActiveTheme, Colorize, v_flex};
17use crate::{IconName, Size};
18use crate::{RoleOverride, Selectable, StyledExt, h_flex};
19use crate::{Sizable, StyleSized};
20use gpui_base::InputBase as BaseInput;
21use rust_i18n::t;
22
23use super::state::{TextInputState, sync_focused_input_registry};
24use super::{InputContentType, InputState, sync_native_content_type};
25use crate::ThemeStyled as _;
26
27fn accessibility_role(
28 is_multi_line: bool,
29 content_type: Option<InputContentType>,
30 role: RoleOverride,
31) -> Option<Role> {
32 role.resolve(|| {
33 if is_multi_line {
34 return Role::MultilineTextInput;
35 }
36
37 match content_type {
38 None => Role::TextInput,
39 Some(InputContentType::TelephoneNumber) => Role::PhoneNumberInput,
40 Some(InputContentType::EmailAddress) => Role::EmailInput,
41 Some(InputContentType::Url) => Role::UrlInput,
42 Some(InputContentType::Password | InputContentType::NewPassword) => Role::PasswordInput,
43 Some(InputContentType::DateTime) => Role::DateTimeInput,
44 Some(InputContentType::Birthdate) => Role::DateInput,
45 Some(
46 InputContentType::Name
47 | InputContentType::NamePrefix
48 | InputContentType::GivenName
49 | InputContentType::MiddleName
50 | InputContentType::FamilyName
51 | InputContentType::NameSuffix
52 | InputContentType::Nickname
53 | InputContentType::JobTitle
54 | InputContentType::OrganizationName
55 | InputContentType::Location
56 | InputContentType::FullStreetAddress
57 | InputContentType::StreetAddressLine1
58 | InputContentType::StreetAddressLine2
59 | InputContentType::AddressCity
60 | InputContentType::AddressState
61 | InputContentType::AddressCityAndState
62 | InputContentType::Sublocality
63 | InputContentType::CountryName
64 | InputContentType::PostalCode
65 | InputContentType::CreditCardNumber
66 | InputContentType::CreditCardName
67 | InputContentType::CreditCardGivenName
68 | InputContentType::CreditCardMiddleName
69 | InputContentType::CreditCardFamilyName
70 | InputContentType::CreditCardSecurityCode
71 | InputContentType::CreditCardExpiration
72 | InputContentType::CreditCardExpirationMonth
73 | InputContentType::CreditCardExpirationYear
74 | InputContentType::CreditCardType
75 | InputContentType::Username
76 | InputContentType::OneTimeCode
77 | InputContentType::ShipmentTrackingNumber
78 | InputContentType::FlightNumber
79 | InputContentType::BirthdateDay
80 | InputContentType::BirthdateMonth
81 | InputContentType::BirthdateYear
82 | InputContentType::CellularEid
83 | InputContentType::CellularImei,
84 ) => Role::TextInput,
85 }
86 })
87}
88
89fn exposes_accessibility_value(masked: bool, content_type: Option<InputContentType>) -> bool {
90 !masked
91 && !matches!(
92 content_type,
93 Some(InputContentType::Password | InputContentType::NewPassword)
94 )
95}
96
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 id: Option<ElementId>,
115 state: TextInputState,
116 style: StyleRefinement,
117 size: Size,
118 prefix: Option<AnyElement>,
119 suffix: Option<AnyElement>,
120 height: Option<DefiniteLength>,
121 appearance: bool,
122 cleanable: bool,
123 mask_toggle: bool,
124 disabled: bool,
125 readonly: bool,
126 bordered: bool,
127 focus_bordered: bool,
128 tab_index: isize,
129 selected: bool,
130 content_type: Option<InputContentType>,
131 role: RoleOverride,
132 accessibility_id: Option<SharedString>,
133 aria_label: Option<SharedString>,
134
135 context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
139
140 paste_handler: Option<Rc<dyn Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool>>,
143}
144
145impl Sizable for Input {
146 fn with_size(mut self, size: impl Into<Size>) -> Self {
147 self.size = size.into();
148 self
149 }
150}
151
152impl Selectable for Input {
153 fn selected(mut self, selected: bool) -> Self {
154 self.selected = selected;
155 self
156 }
157
158 fn is_selected(&self) -> bool {
159 self.selected
160 }
161}
162
163impl crate::FocusableExt for Input {
164 fn focus_ring(mut self, enabled: bool) -> Self {
165 self.focus_bordered = enabled;
166 self
167 }
168
169 fn is_focus_ring_enabled(&self) -> bool {
170 self.focus_bordered
171 }
172}
173
174impl Input {
175 pub fn token<R: IntoElement>(
179 mut self,
180 render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static,
181 ) -> Self {
182 self.token_renderer = Some(Rc::new(move |token, window, cx| {
183 render(token, window, cx).into_any_element()
184 }));
185 self
186 }
187 pub fn on_token_click(
189 mut self,
190 listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static,
191 ) -> Self {
192 self.token_click_listener = Some(Rc::new(listener));
193 self
194 }
195
196 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
198 self.id = Some(id.into());
199 self
200 }
201
202 pub fn new(state: &Entity<InputState>) -> Self {
204 Self::with_state(state.clone().into())
205 }
206
207 pub(crate) fn from_state(state: impl Into<TextInputState>) -> Self {
212 Self::with_state(state.into())
213 }
214
215 fn with_state(state: TextInputState) -> Self {
216 Self {
217 id: None,
218 state,
219 size: Size::default(),
220 style: StyleRefinement::default(),
221 prefix: None,
222 suffix: None,
223 height: None,
224 appearance: true,
225 cleanable: false,
226 mask_toggle: false,
227 disabled: false,
228 readonly: false,
229 bordered: true,
230 focus_bordered: true,
231 tab_index: 0,
232 selected: false,
233 content_type: None,
234 role: RoleOverride::default(),
235 accessibility_id: None,
236 aria_label: None,
237 context_menu_builder: None,
238 paste_handler: None,
239 token_renderer: None,
240 token_click_listener: None,
241 }
242 }
243
244 pub(crate) fn state(&self) -> &TextInputState {
248 &self.state
249 }
250
251 pub(crate) fn is_disabled(&self) -> bool {
252 self.disabled
253 }
254
255 pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
256 self.accessibility_id = Some(id.into());
257 self
258 }
259
260 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
261 self.aria_label = Some(label.into());
262 self
263 }
264
265 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
266 self.prefix = Some(prefix.into_any_element());
267 self
268 }
269
270 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
271 self.suffix = Some(suffix.into_any_element());
272 self
273 }
274
275 pub fn h_full(mut self) -> Self {
277 self.height = Some(relative(1.));
278 self
279 }
280
281 pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
283 self.height = Some(height.into());
284 self
285 }
286
287 pub fn appearance(mut self, appearance: bool) -> Self {
289 self.appearance = appearance;
290 self
291 }
292
293 pub fn bordered(mut self, bordered: bool) -> Self {
295 self.bordered = bordered;
296 self
297 }
298
299 pub fn focus_bordered(mut self, bordered: bool) -> Self {
301 self.focus_bordered = bordered;
302 self
303 }
304
305 pub fn cleanable(mut self, cleanable: bool) -> Self {
307 self.cleanable = cleanable;
308 self
309 }
310
311 pub fn mask_toggle(mut self) -> Self {
313 self.mask_toggle = true;
314 self
315 }
316
317 pub fn content_type(mut self, content_type: InputContentType) -> Self {
322 self.content_type = Some(content_type);
323 self
324 }
325
326 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
330 self.role = role.into();
331 self
332 }
333
334 pub fn disabled(mut self, disabled: bool) -> Self {
336 self.disabled = disabled;
337 self
338 }
339
340 pub fn readonly(mut self, readonly: bool) -> Self {
346 self.readonly = readonly;
347 self
348 }
349
350 pub fn tab_index(mut self, index: isize) -> Self {
352 self.tab_index = index;
353 self
354 }
355
356 pub fn context_menu(
361 mut self,
362 f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
363 ) -> Self {
364 self.context_menu_builder = Some(Rc::new(f));
365 self
366 }
367
368 pub fn on_paste(
384 mut self,
385 handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
386 ) -> Self {
387 self.paste_handler = Some(Rc::new(handler));
388 self
389 }
390
391 fn render_touch_selection(
398 state: &TextInputState,
399 window: &Window,
400 cx: &App,
401 ) -> Vec<AnyElement> {
402 if state.touch_selection(cx).is_none() {
403 return Vec::new();
404 }
405 let capabilities = state.context_menu_capabilities(cx);
406 let editable = capabilities.is_editable();
407 let copyable = capabilities.is_copyable();
408 let pasteable = editable;
412 let selectable = state.text(cx).len() > 0 && !state.is_all_selected(cx);
413 let focus_handle = state.presentation(cx).focus_handle().clone();
414
415 let dispatch = {
416 let focus_handle = focus_handle.clone();
417 move |action: &dyn gpui::Action, window: &mut Window, cx: &mut App| {
418 focus_handle.dispatch_action(action, window, cx);
419 }
420 };
421 let mut items = Vec::with_capacity(4);
422 if editable && copyable {
423 let dispatch = dispatch.clone();
424 items.push(EditMenuItem::new(t!("Input.Cut"), move |window, cx| {
425 dispatch(&gpui_base::input::Cut, window, cx);
426 }));
427 }
428 if copyable {
429 let dispatch = dispatch.clone();
430 let state = state.clone();
431 items.push(EditMenuItem::new(t!("Input.Copy"), move |window, cx| {
432 dispatch(&gpui_base::input::Copy, window, cx);
433 state.close_edit_menu(cx);
434 }));
435 }
436 if pasteable {
437 let dispatch = dispatch.clone();
438 items.push(EditMenuItem::new(t!("Input.Paste"), move |window, cx| {
439 dispatch(&gpui_base::input::Paste, window, cx);
440 }));
441 }
442 if selectable {
443 let state = state.clone();
444 items.push(EditMenuItem::new(
445 t!("Input.Select All"),
446 move |window, cx| state.select_all_from_edit_menu(window, cx),
447 ));
448 }
449
450 let drag_state = state.clone();
451 let source_state = state.clone();
452 TouchSelectionOverlay::new(
453 ("input-touch-selection", state.entity_id()),
454 move |_, cx| source_state.touch_selection(cx),
455 )
456 .handles(move |edge, phase, position, _, cx| match phase {
457 TouchPhase::Started => drag_state.begin_edge_drag(edge, position, cx),
458 TouchPhase::Moved => drag_state.update_edge_drag(position, cx),
459 TouchPhase::Ended | TouchPhase::Cancelled => drag_state.end_edge_drag(cx),
460 })
461 .items(items)
462 .into_elements(window, cx)
463 }
464
465 fn render_toggle_mask_button(state: &TextInputState, cx: &App) -> impl IntoElement {
466 let masked = state.presentation(cx).is_masked();
467 Button::new("toggle-mask")
468 .icon(if masked {
469 IconName::Eye
470 } else {
471 IconName::EyeOff
472 })
473 .xsmall()
474 .text()
475 .tab_stop(false)
476 .on_click({
477 let state = state.clone();
478 move |_, window, cx| state.toggle_masked(window, cx)
479 })
480 }
481
482 fn handle_accessibility_set_value(
483 state: &TextInputState,
484 data: Option<&gpui::accesskit::ActionData>,
485 window: &mut Window,
486 cx: &mut App,
487 ) {
488 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
489 return;
490 };
491 if !state.presentation(cx).is_editable() {
492 return;
493 }
494 state.replace_all(value.to_string(), window, cx);
495 }
496
497 fn handle_accessibility_focus(state: &TextInputState, window: &mut Window, cx: &mut App) {
498 if !state.presentation(cx).is_disabled() {
499 state.focus(window, cx);
500 }
501 }
502
503 fn render_editor(
505 input_state: TextInputState,
506 search_panel: Option<AnyElement>,
507 _: &Window,
508 ) -> impl IntoElement {
509 v_flex().size_full().children(search_panel).child(
510 div()
511 .relative()
512 .flex_1()
513 .child(input_state.into_any_element()),
514 )
515 }
516}
517
518impl Styled for Input {
519 fn style(&mut self) -> &mut StyleRefinement {
520 &mut self.style
521 }
522}
523
524impl RenderOnce for Input {
525 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
526 const LINE_HEIGHT: Rems = Rems(1.25);
527 let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
528 let state = self.state.clone();
529 state.install_token_presentation(
530 Some(self.token_renderer.unwrap_or_else(|| {
531 Rc::new(|token, _, _| super::InputToken::new(token).into_any_element())
532 })),
533 self.token_click_listener,
534 matches!(
535 self.content_type,
536 Some(InputContentType::Password | InputContentType::NewPassword)
537 ),
538 cx,
539 );
540 sync_focused_input_registry(&state, window, cx);
542
543 state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx);
544 state.set_editor_style(
545 gpui_base::input::InputEditorStyle {
546 foreground: cx.theme().foreground,
547 muted_foreground: cx.theme().muted_foreground,
548 background: cx.theme().editor_background(),
549 border: cx.theme().border,
550 selection: cx.theme().selection,
551 caret: cx.theme().caret,
552 diagnostics: gpui_base::input::DiagnosticColors {
553 error: cx.theme().highlight_theme.style.status.error(cx),
554 warning: cx.theme().highlight_theme.style.status.warning(cx),
555 info: cx.theme().highlight_theme.style.status.info(cx),
556 hint: cx.theme().highlight_theme.style.status.hint(cx),
557 },
558 highlight_styles: cx.theme().highlight_theme.clone(),
559 editor_invisible: cx.theme().highlight_theme.style.editor_invisible,
560 editor_active_line: cx.theme().highlight_theme.style.editor_active_line,
561 editor_gutter_background: cx.theme().highlight_theme.style.editor_gutter_background,
562 fold_icon_renderer: Some(Rc::new(|ix, is_folded| {
563 Button::new(("fold-icon", ix))
564 .ghost()
565 .icon(if is_folded {
566 IconName::ChevronRight
567 } else {
568 IconName::ChevronDown
569 })
570 .xsmall()
571 .rounded(ButtonRounded::Small)
572 .size(px(14.))
573 .selected(is_folded)
574 .into_any_element()
575 })),
576 },
577 cx,
578 );
579 state.set_editor_paddings(
580 if state.presentation(cx).is_multi_line() {
581 Edges {
582 top: self.size.input_py(),
583 right: self.size.input_px(),
584 bottom: self.size.input_py(),
585 left: if state.presentation(cx).is_code_editor() {
586 self.size.input_px().min(px(6.))
587 } else {
588 self.size.input_px()
589 },
590 }
591 } else {
592 Edges::default()
593 },
594 cx,
595 );
596 state.set_disabled(self.disabled, cx);
597 state.set_readonly(self.readonly, cx);
598 state.set_text_align(text_align, cx);
599 let custom = self.context_menu_builder.clone();
600 state.on_context_menu(
601 Rc::new(move |_, capabilities, position, window, cx| {
602 let menu = if let Some(custom) = custom.as_ref() {
603 custom(NativeMenu::new(), window, cx)
604 } else {
605 let enabled = !capabilities.is_disabled();
606 let editable = enabled && !capabilities.is_readonly();
609 let mut menu = NativeMenu::new();
610 if capabilities.is_code_editor() {
611 menu = menu
612 .menu_with_disabled(
613 t!("Input.Go to Definition"),
614 !(enabled && capabilities.has_definition()),
615 Box::new(gpui_base::input::GoToDefinition),
616 )
617 .menu_with_disabled(
618 t!("Input.Show Code Actions"),
619 !(editable && capabilities.has_code_actions()),
620 Box::new(gpui_base::input::ToggleCodeActions),
621 )
622 .separator();
623 }
624 menu.menu_with_disabled(
625 t!("Input.Cut"),
626 !(editable && capabilities.is_copyable()),
627 Box::new(gpui_base::input::Cut),
628 )
629 .menu_with_disabled(
630 t!("Input.Copy"),
631 !capabilities.is_copyable(),
632 Box::new(gpui_base::input::Copy),
633 )
634 .menu_with_disabled(
638 t!("Input.Paste"),
639 !editable,
640 Box::new(gpui_base::input::Paste),
641 )
642 .separator()
643 .menu(
644 t!("Input.Select All"),
645 Box::new(gpui_base::input::SelectAll),
646 )
647 };
648 menu.show(position, window, cx);
649 }),
650 cx,
651 );
652 let paste_handler = self
655 .paste_handler
656 .clone()
657 .filter(|_| state.presentation(cx).is_editable());
658 let mut overlays = state.render_overlays(window, cx);
659 overlays
660 .floating
661 .extend(Self::render_touch_selection(&state, window, cx));
662
663 let presentation = state.presentation(cx);
664 let content_type = self.content_type;
665 let disabled = self.disabled;
666 let is_multi_line = presentation.is_multi_line();
667 let accessibility_role = accessibility_role(is_multi_line, content_type, self.role);
668 let accessibility_state = state.clone();
669 let accessibility_value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
672 && exposes_accessibility_value(presentation.is_masked(), content_type))
673 .then(|| state.text(cx).to_string());
674 let input_focused =
675 presentation.focus_handle().is_focused(window) && !presentation.is_disabled();
676 if input_focused {
677 sync_native_content_type(window, content_type, presentation.is_editable());
678 }
679 let frame_focus_handle = window
680 .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| {
681 cx.focus_handle()
682 })
683 .read(cx)
684 .clone();
685 let focused = input_focused
686 || (frame_focus_handle.contains_focused(window, cx) && !presentation.is_disabled());
687
688 let gap_x = match self.size {
689 Size::Small => px(4.),
690 Size::Large => px(8.),
691 _ => px(6.),
692 };
693
694 let (bg, _) = input_style(presentation.is_disabled(), cx);
695 let bg = if presentation.is_code_editor() {
696 cx.theme().editor_background()
697 } else {
698 bg
699 };
700 let bg = if presentation.is_disabled() {
701 bg.opacity(0.5)
702 } else {
703 bg
704 };
705 let prefix = self.prefix;
706 let suffix = self.suffix;
707 let show_clear_button = self.cleanable
708 && presentation.is_editable()
709 && !presentation.is_loading()
710 && state.text(cx).len() > 0
711 && !presentation.is_multi_line();
712 let has_suffix =
713 suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
714
715 let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
716
717 let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
719
720 let aria_label = match self.aria_label {
721 Some(label) => Some(label),
722 None if placeholder_is_mask => None,
723 None => placeholder.clone(),
724 };
725 let id = self
726 .id
727 .unwrap_or_else(|| ("input", state.entity_id()).into());
728 BaseInput::new(id)
729 .focused(focused)
730 .disabled(disabled)
731 .track_focus(&frame_focus_handle)
732 .when(disabled, |this| {
733 this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
734 })
735 .styles(|styles| {
736 styles.focused(|style| {
737 style.when(
738 self.appearance && self.bordered && self.focus_bordered,
739 |style| style.border_1().border_color(cx.theme().ring),
740 )
741 })
742 })
743 .role(accessibility_role)
744 .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
745 .when_some(aria_label, |this, label| this.aria_label(label))
746 .when_some(placeholder, |this, placeholder| {
747 this.aria_placeholder(placeholder)
748 })
749 .when_some(accessibility_value, |this, value| this.aria_value(value))
750 .on_a11y_action(AccessibleAction::Focus, {
751 let state = state.clone();
752 move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx)
753 })
754 .when(presentation.is_editable(), |this| {
755 this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
756 Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
757 })
758 })
759 .flex()
760 .size_full()
761 .line_height(LINE_HEIGHT)
762 .when(!is_multi_line, |this| {
763 this.input_px(self.size).input_py(self.size)
764 })
765 .input_h(self.size)
766 .input_text_size(self.size)
767 .items_center()
768 .when(presentation.is_multi_line(), |this| {
769 this.h_auto()
770 .when_some(self.height, |this, height| this.h(height))
771 })
772 .when(self.appearance, |this| {
773 this.bg(bg)
774 .rounded(cx.theme().radius)
775 .when(self.bordered, |this| {
776 this.border_1().border_color(cx.theme().input)
777 })
778 })
779 .items_center()
780 .gap(gap_x)
781 .refine_style(&self.style)
782 .when(
783 focused && self.appearance && self.bordered && self.focus_bordered,
784 |this| this.focus_ring_style(window, cx),
785 )
786 .children(prefix.map(|p| {
787 div()
788 .when(presentation.is_disabled(), |this| this.opacity(0.5))
789 .child(p)
790 }))
791 .when(presentation.is_multi_line(), |this| {
792 this.child(Self::render_editor(state.clone(), overlays.search, window))
793 })
794 .when(!presentation.is_multi_line(), |this| {
795 this.child(state.clone().into_any_element())
796 })
797 .when(has_suffix, |this| {
798 this.pr(self.size.input_px()).child(
799 h_flex()
800 .id("suffix")
801 .gap(gap_x)
802 .items_center()
803 .cursor_default()
804 .when(presentation.is_disabled(), |this| this.opacity(0.5))
805 .when(presentation.is_loading(), |this| {
806 this.child(Spinner::new().color(cx.theme().muted_foreground))
807 })
808 .when(self.mask_toggle, |this| {
809 this.child(Self::render_toggle_mask_button(&state, cx))
810 })
811 .when(show_clear_button, |this| {
812 this.child(clear_button(cx).on_click({
813 let state = state.clone();
814 move |_, window, cx| {
815 state.clean(window, cx);
816 state.focus(window, cx);
817 }
818 }))
819 })
820 .children(suffix),
821 )
822 })
823 .relative()
824 .children(overlays.floating)
825 .when_some(paste_handler, |this, handler| {
826 this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
827 if let Some(clipboard) = cx.read_from_clipboard() {
828 if handler(&clipboard, window, cx) {
829 cx.stop_propagation();
830 }
831 }
832 })
833 })
834 .render(window, cx)
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use crate::input::AnyInputState;
842
843 #[test]
844 fn content_types_map_to_accessibility_roles() {
845 let cases = [
846 (None, Role::TextInput),
847 (Some(InputContentType::Name), Role::TextInput),
848 (Some(InputContentType::NamePrefix), Role::TextInput),
849 (Some(InputContentType::GivenName), Role::TextInput),
850 (Some(InputContentType::MiddleName), Role::TextInput),
851 (Some(InputContentType::FamilyName), Role::TextInput),
852 (Some(InputContentType::NameSuffix), Role::TextInput),
853 (Some(InputContentType::Nickname), Role::TextInput),
854 (Some(InputContentType::JobTitle), Role::TextInput),
855 (Some(InputContentType::OrganizationName), Role::TextInput),
856 (Some(InputContentType::Location), Role::TextInput),
857 (Some(InputContentType::FullStreetAddress), Role::TextInput),
858 (Some(InputContentType::StreetAddressLine1), Role::TextInput),
859 (Some(InputContentType::StreetAddressLine2), Role::TextInput),
860 (Some(InputContentType::AddressCity), Role::TextInput),
861 (Some(InputContentType::AddressState), Role::TextInput),
862 (Some(InputContentType::AddressCityAndState), Role::TextInput),
863 (Some(InputContentType::Sublocality), Role::TextInput),
864 (Some(InputContentType::CountryName), Role::TextInput),
865 (Some(InputContentType::PostalCode), Role::TextInput),
866 (
867 Some(InputContentType::TelephoneNumber),
868 Role::PhoneNumberInput,
869 ),
870 (Some(InputContentType::EmailAddress), Role::EmailInput),
871 (Some(InputContentType::Url), Role::UrlInput),
872 (Some(InputContentType::CreditCardNumber), Role::TextInput),
873 (Some(InputContentType::CreditCardName), Role::TextInput),
874 (Some(InputContentType::CreditCardGivenName), Role::TextInput),
875 (
876 Some(InputContentType::CreditCardMiddleName),
877 Role::TextInput,
878 ),
879 (
880 Some(InputContentType::CreditCardFamilyName),
881 Role::TextInput,
882 ),
883 (
884 Some(InputContentType::CreditCardSecurityCode),
885 Role::TextInput,
886 ),
887 (
888 Some(InputContentType::CreditCardExpiration),
889 Role::TextInput,
890 ),
891 (
892 Some(InputContentType::CreditCardExpirationMonth),
893 Role::TextInput,
894 ),
895 (
896 Some(InputContentType::CreditCardExpirationYear),
897 Role::TextInput,
898 ),
899 (Some(InputContentType::CreditCardType), Role::TextInput),
900 (Some(InputContentType::Username), Role::TextInput),
901 (Some(InputContentType::Password), Role::PasswordInput),
902 (Some(InputContentType::NewPassword), Role::PasswordInput),
903 (Some(InputContentType::OneTimeCode), Role::TextInput),
904 (
905 Some(InputContentType::ShipmentTrackingNumber),
906 Role::TextInput,
907 ),
908 (Some(InputContentType::FlightNumber), Role::TextInput),
909 (Some(InputContentType::DateTime), Role::DateTimeInput),
910 (Some(InputContentType::Birthdate), Role::DateInput),
911 (Some(InputContentType::BirthdateDay), Role::TextInput),
912 (Some(InputContentType::BirthdateMonth), Role::TextInput),
913 (Some(InputContentType::BirthdateYear), Role::TextInput),
914 (Some(InputContentType::CellularEid), Role::TextInput),
915 (Some(InputContentType::CellularImei), Role::TextInput),
916 ];
917
918 for (content_type, role) in cases {
919 assert_eq!(
920 accessibility_role(false, content_type, RoleOverride::Implicit),
921 Some(role)
922 );
923 }
924 }
925
926 #[test]
927 fn multiline_inputs_keep_multiline_accessibility_role() {
928 assert_eq!(
929 accessibility_role(
930 true,
931 Some(InputContentType::Password),
932 RoleOverride::Implicit
933 ),
934 Some(Role::MultilineTextInput)
935 );
936 }
937
938 #[test]
939 fn explicit_accessibility_role_overrides_defaults() {
940 assert_eq!(
941 accessibility_role(
942 false,
943 Some(InputContentType::Password),
944 Role::TextInput.into()
945 ),
946 Some(Role::TextInput)
947 );
948 assert_eq!(
949 accessibility_role(
950 true,
951 Some(InputContentType::Password),
952 Role::TextInput.into()
953 ),
954 Some(Role::TextInput)
955 );
956 }
957
958 #[test]
959 fn presentational_role_emits_no_accessibility_node() {
960 assert_eq!(
961 accessibility_role(
962 false,
963 Some(InputContentType::Password),
964 RoleOverride::Presentational
965 ),
966 None
967 );
968 assert_eq!(
969 accessibility_role(true, None, RoleOverride::Presentational),
970 None
971 );
972 }
973
974 #[test]
975 fn role_option_converts_to_the_matching_override() {
976 assert_eq!(
977 RoleOverride::from(Some(Role::Button)),
978 RoleOverride::Role(Role::Button)
979 );
980 assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
981 }
982
983 #[gpui::test]
984 fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
985 use gpui::{AppContext as _, Render};
986
987 struct Probe;
988 impl Render for Probe {
989 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
990 div()
991 }
992 }
993
994 cx.update(crate::init);
995 let _ = cx.add_window_view(|window, cx| {
996 let state = cx.new(|cx| InputState::new(window, cx));
997
998 assert!(Input::new(&state).paste_handler.is_none());
999 let input = Input::new(&state).on_paste(|_, _, _| true);
1000 assert!(input.paste_handler.is_some());
1001 Probe
1002 });
1003 }
1004
1005 #[gpui::test]
1006 fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
1007 use crate::ElementExt as _;
1008 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1009 use std::sync::{Arc, Mutex};
1010
1011 type EmittedState = Option<(Option<String>, bool)>;
1012
1013 struct InputA11yProbe {
1014 state: Entity<InputState>,
1015 emitted: Arc<Mutex<EmittedState>>,
1016 }
1017
1018 impl Render for InputA11yProbe {
1019 fn render(
1020 &mut self,
1021 _window: &mut Window,
1022 _cx: &mut gpui::Context<Self>,
1023 ) -> impl IntoElement {
1024 let state = self.state.clone();
1025 let emitted = self.emitted.clone();
1026 div()
1027 .child(Input::new(&state))
1028 .on_prepaint(move |_, window, cx| {
1029 let input = Input::new(&state).render(window, cx).into_element();
1030 let mut node = gpui::accesskit::Node::new(Role::TextInput);
1031 input.write_a11y_info(&mut node);
1032 *emitted.lock().unwrap() = Some((
1033 node.value().map(ToOwned::to_owned),
1034 node.supports_action(AccessibleAction::SetValue),
1035 ));
1036 })
1037 }
1038 }
1039
1040 cx.update(crate::init);
1041 let emitted = Arc::new(Mutex::new(None));
1042 let captured = emitted.clone();
1043 let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1044 state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
1045 emitted,
1046 });
1047 cx.update(|window, cx| {
1048 let _ = window.draw(cx);
1049 });
1050 let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
1053 assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
1054
1055 let state = probe.read_with(cx, |probe, _| probe.state.clone());
1056 let base: TextInputState = state.clone().into();
1057 cx.update(|window, cx| {
1058 Input::handle_accessibility_set_value(&base, None, window, cx);
1059 Input::handle_accessibility_focus(&base, window, cx);
1060 assert!(base.presentation(cx).focus_handle().is_focused(window));
1061 window.draw(cx).clear(cx);
1062 });
1063 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1064
1065 let changes = std::rc::Rc::new(std::cell::Cell::new(0));
1066 let observed = changes.clone();
1067 let _subscription = cx.update(|_, cx| {
1068 cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| {
1069 if matches!(event, super::super::InputEvent::Change) {
1070 observed.set(observed.get() + 1);
1071 }
1072 })
1073 });
1074 let action = gpui::accesskit::ActionData::Value("updated🦀".into());
1075 cx.update(|window, cx| {
1076 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1077 });
1078 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1079 assert_eq!(changes.get(), 1);
1080 for disabled in [false, true] {
1081 cx.update(|window, cx| {
1082 base.set_disabled(disabled, cx);
1083 base.set_readonly(!disabled, cx);
1084 window.blur(cx);
1085 Input::handle_accessibility_focus(&base, window, cx);
1086 assert_eq!(
1087 base.presentation(cx).focus_handle().is_focused(window),
1088 !disabled
1089 );
1090 let action = gpui::accesskit::ActionData::Value("rejected".into());
1091 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1092 });
1093 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1094 assert_eq!(changes.get(), 1);
1095 }
1096 cx.update(|window, cx| {
1097 base.set_disabled(false, cx);
1098 base.set_readonly(false, cx);
1099 Input::handle_accessibility_focus(&base, window, cx);
1100 window.draw(cx).clear(cx);
1101 window.dispatch_action(Box::new(super::super::Undo), cx);
1102 });
1103 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1104 cx.update(|window, cx| {
1105 state.update(cx, |state, cx| state.set_masked(true, window, cx));
1106 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1107 window.draw(cx).clear(cx);
1108 });
1109 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1110 assert_eq!(*captured.lock().unwrap(), Some((None, true)));
1111 }
1112
1113 #[gpui::test]
1114 fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) {
1115 use gpui::{AppContext as _, Render};
1116
1117 struct Probe(Entity<crate::input::EditorState>);
1118
1119 impl Render for Probe {
1120 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1121 div().child(crate::input::Editor::new(&self.0))
1122 }
1123 }
1124
1125 cx.update(crate::init);
1126 let (probe, cx) = cx.add_window_view(|window, cx| {
1127 Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust")))
1128 });
1129 let editor = probe.read_with(cx, |probe, _| probe.0.clone());
1130 let state: TextInputState = editor.clone().into();
1131 let action = gpui::accesskit::ActionData::Value("(".into());
1132
1133 cx.update(|window, cx| {
1134 Input::handle_accessibility_set_value(&state, Some(&action), window, cx)
1135 });
1136 assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "(");
1137 }
1138
1139 #[gpui::test]
1140 fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
1141 use crate::ElementExt as _;
1142 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1143 use std::sync::{Arc, Mutex};
1144
1145 type EmittedIds = Vec<Option<String>>;
1146
1147 struct InputA11yProbe {
1148 state: Entity<InputState>,
1149 emitted: Arc<Mutex<EmittedIds>>,
1150 }
1151
1152 impl Render for InputA11yProbe {
1153 fn render(
1154 &mut self,
1155 _window: &mut Window,
1156 _cx: &mut gpui::Context<Self>,
1157 ) -> impl IntoElement {
1158 let state = self.state.clone();
1159 let emitted = self.emitted.clone();
1160 div().on_prepaint(move |_, window, cx| {
1161 let mut author_id_of = |input: Input| {
1162 let mut node = gpui::accesskit::Node::new(Role::TextInput);
1163 input
1164 .render(window, cx)
1165 .into_element()
1166 .write_a11y_info(&mut node);
1167 node.author_id().map(ToOwned::to_owned)
1168 };
1169
1170 *emitted.lock().unwrap() = vec![
1171 author_id_of(Input::new(&state)),
1172 author_id_of(Input::new(&state).accessibility_id("search.query")),
1173 ];
1174 })
1175 }
1176 }
1177
1178 cx.update(crate::init);
1179 let emitted = Arc::new(Mutex::new(Vec::new()));
1180 let captured = emitted.clone();
1181 let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1182 state: cx.new(|cx| InputState::new(window, cx)),
1183 emitted,
1184 });
1185 cx.update(|window, cx| {
1186 let _ = window.draw(cx);
1187 });
1188
1189 assert_eq!(
1190 *captured.lock().unwrap(),
1191 vec![None, Some("search.query".into())]
1192 );
1193 }
1194
1195 #[test]
1196 fn accessibility_value_is_hidden_for_secret_inputs() {
1197 assert!(exposes_accessibility_value(false, None));
1198 assert!(!exposes_accessibility_value(true, None));
1199 assert!(!exposes_accessibility_value(
1200 false,
1201 Some(InputContentType::Password)
1202 ));
1203 assert!(!exposes_accessibility_value(
1204 false,
1205 Some(InputContentType::NewPassword)
1206 ));
1207 }
1208
1209 #[gpui::test]
1210 fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1211 use crate::{Root, WindowExt as _};
1212 use gpui::{AppContext as _, Render};
1213
1214 struct Probe {
1215 input: Entity<InputState>,
1216 textarea: Entity<crate::input::TextareaState>,
1217 editor: Entity<crate::input::EditorState>,
1218 otp: Entity<gpui_base::OtpState>,
1219 other: gpui::FocusHandle,
1220 }
1221 impl Render for Probe {
1222 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1223 div()
1224 .child(div().track_focus(&self.other))
1225 .child(Input::new(&self.input))
1226 .child(crate::input::Textarea::new(&self.textarea))
1227 .child(crate::input::Editor::new(&self.editor))
1228 .child(crate::input::OtpInput::new(&self.otp))
1229 }
1230 }
1231
1232 cx.update(crate::init);
1233 let mut input = None;
1234 let mut textarea = None;
1235 let mut editor = None;
1236 let mut other_focus = None;
1237 let mut otp = None;
1238 let window = cx.update(|cx| {
1239 cx.open_window(Default::default(), |window, cx| {
1240 let state = cx.new(|cx| InputState::new(window, cx));
1241 let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1242 let editor_state =
1243 cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1244 let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1245 input = Some(state.clone());
1246 textarea = Some(textarea_state.clone());
1247 editor = Some(editor_state.clone());
1248 otp = Some(otp_state.clone());
1249 let other = cx.focus_handle();
1250 other_focus = Some(other.clone());
1251 let probe = cx.new(|_| Probe {
1252 input: state,
1253 textarea: textarea_state,
1254 editor: editor_state,
1255 otp: otp_state,
1256 other,
1257 });
1258 cx.new(|cx| Root::new(probe, window, cx))
1259 })
1260 .unwrap()
1261 });
1262 let input = input.unwrap();
1263 let textarea = textarea.unwrap();
1264 let editor = editor.unwrap();
1265 let otp = otp.unwrap();
1266 let other_focus = other_focus.unwrap();
1267 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1268
1269 let cases: Vec<AnyInputState> = vec![
1271 input.clone().into(),
1272 textarea.clone().into(),
1273 editor.clone().into(),
1274 otp.clone().into(),
1275 ];
1276 for expected in cases {
1277 cx.update(|window, cx| {
1278 let _ = window.draw(cx);
1279 });
1280 cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1281 cx.run_until_parked();
1282 cx.update(|window, cx| {
1283 let _ = window.draw(cx);
1284 });
1285 assert_eq!(
1286 cx.update(|window, cx| window.focused_input(cx)),
1287 Some(expected)
1288 );
1289
1290 cx.update(|window, cx| other_focus.clone().focus(window, cx));
1291 cx.run_until_parked();
1292 cx.update(|window, cx| {
1293 let _ = window.draw(cx);
1294 });
1295 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1296 }
1297 }
1298
1299 #[gpui::test]
1300 fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1301 use crate::{Root, WindowExt as _};
1302 use gpui::{AppContext as _, Render};
1303
1304 struct Probe {
1305 input: Entity<InputState>,
1306 show_input: bool,
1307 other: gpui::FocusHandle,
1308 }
1309 impl Render for Probe {
1310 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1311 let base = div().child(div().track_focus(&self.other));
1312 if self.show_input {
1313 base.child(Input::new(&self.input))
1314 } else {
1315 base
1316 }
1317 }
1318 }
1319
1320 cx.update(crate::init);
1321 let mut input = None;
1322 let mut other_focus = None;
1323 let mut probe_entity = None;
1324 let window = cx.update(|cx| {
1325 cx.open_window(Default::default(), |window, cx| {
1326 let state = cx.new(|cx| InputState::new(window, cx));
1327 input = Some(state.clone());
1328 let other = cx.focus_handle();
1329 other_focus = Some(other.clone());
1330 let probe = cx.new(|_| Probe {
1331 input: state,
1332 show_input: true,
1333 other,
1334 });
1335 probe_entity = Some(probe.clone());
1336 cx.new(|cx| Root::new(probe, window, cx))
1337 })
1338 .unwrap()
1339 });
1340 let input: AnyInputState = input.unwrap().into();
1341 let other_focus = other_focus.unwrap();
1342 let probe = probe_entity.unwrap();
1343 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1344
1345 cx.update(|window, cx| {
1346 let _ = window.draw(cx);
1347 });
1348 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1349 cx.run_until_parked();
1350 cx.update(|window, cx| {
1351 let _ = window.draw(cx);
1352 });
1353 assert_eq!(
1354 cx.update(|window, cx| window.focused_input(cx)),
1355 Some(input.clone())
1356 );
1357
1358 cx.update(|window, cx| {
1362 probe.update(cx, |probe, cx| {
1363 probe.show_input = false;
1364 cx.notify();
1365 });
1366 other_focus.focus(window, cx);
1367 });
1368 cx.run_until_parked();
1369 cx.update(|window, cx| {
1370 let _ = window.draw(cx);
1371 });
1372 assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1373 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1374 }
1375}