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