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 id: Option<ElementId>,
113 state: TextInputState,
114 style: StyleRefinement,
115 size: Size,
116 prefix: Option<AnyElement>,
117 suffix: Option<AnyElement>,
118 height: Option<DefiniteLength>,
119 appearance: bool,
120 cleanable: bool,
121 mask_toggle: bool,
122 disabled: bool,
123 readonly: bool,
124 bordered: bool,
125 focus_bordered: bool,
126 tab_index: isize,
127 selected: bool,
128 content_type: Option<InputContentType>,
129 role: RoleOverride,
130 accessibility_id: Option<SharedString>,
131 aria_label: Option<SharedString>,
132
133 context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
137
138 paste_handler: Option<Rc<dyn Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool>>,
141}
142
143impl Sizable for Input {
144 fn with_size(mut self, size: impl Into<Size>) -> Self {
145 self.size = size.into();
146 self
147 }
148}
149
150impl Selectable for Input {
151 fn selected(mut self, selected: bool) -> Self {
152 self.selected = selected;
153 self
154 }
155
156 fn is_selected(&self) -> bool {
157 self.selected
158 }
159}
160
161impl crate::FocusableExt for Input {
162 fn focus_ring(mut self, enabled: bool) -> Self {
163 self.focus_bordered = enabled;
164 self
165 }
166
167 fn is_focus_ring_enabled(&self) -> bool {
168 self.focus_bordered
169 }
170}
171
172impl Input {
173 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
175 self.id = Some(id.into());
176 self
177 }
178
179 pub fn new(state: &Entity<InputState>) -> Self {
181 Self::with_state(state.clone().into())
182 }
183
184 pub(crate) fn from_state(state: impl Into<TextInputState>) -> Self {
189 Self::with_state(state.into())
190 }
191
192 fn with_state(state: TextInputState) -> Self {
193 Self {
194 id: None,
195 state,
196 size: Size::default(),
197 style: StyleRefinement::default(),
198 prefix: None,
199 suffix: None,
200 height: None,
201 appearance: true,
202 cleanable: false,
203 mask_toggle: false,
204 disabled: false,
205 readonly: false,
206 bordered: true,
207 focus_bordered: true,
208 tab_index: 0,
209 selected: false,
210 content_type: None,
211 role: RoleOverride::default(),
212 accessibility_id: None,
213 aria_label: None,
214 context_menu_builder: None,
215 paste_handler: None,
216 }
217 }
218
219 pub(crate) fn state(&self) -> &TextInputState {
223 &self.state
224 }
225
226 pub(crate) fn is_disabled(&self) -> bool {
227 self.disabled
228 }
229
230 pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
231 self.accessibility_id = Some(id.into());
232 self
233 }
234
235 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
236 self.aria_label = Some(label.into());
237 self
238 }
239
240 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
241 self.prefix = Some(prefix.into_any_element());
242 self
243 }
244
245 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
246 self.suffix = Some(suffix.into_any_element());
247 self
248 }
249
250 pub fn h_full(mut self) -> Self {
252 self.height = Some(relative(1.));
253 self
254 }
255
256 pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
258 self.height = Some(height.into());
259 self
260 }
261
262 pub fn appearance(mut self, appearance: bool) -> Self {
264 self.appearance = appearance;
265 self
266 }
267
268 pub fn bordered(mut self, bordered: bool) -> Self {
270 self.bordered = bordered;
271 self
272 }
273
274 pub fn focus_bordered(mut self, bordered: bool) -> Self {
276 self.focus_bordered = bordered;
277 self
278 }
279
280 pub fn cleanable(mut self, cleanable: bool) -> Self {
282 self.cleanable = cleanable;
283 self
284 }
285
286 pub fn mask_toggle(mut self) -> Self {
288 self.mask_toggle = true;
289 self
290 }
291
292 pub fn content_type(mut self, content_type: InputContentType) -> Self {
297 self.content_type = Some(content_type);
298 self
299 }
300
301 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
305 self.role = role.into();
306 self
307 }
308
309 pub fn disabled(mut self, disabled: bool) -> Self {
311 self.disabled = disabled;
312 self
313 }
314
315 pub fn readonly(mut self, readonly: bool) -> Self {
321 self.readonly = readonly;
322 self
323 }
324
325 pub fn tab_index(mut self, index: isize) -> Self {
327 self.tab_index = index;
328 self
329 }
330
331 pub fn context_menu(
335 mut self,
336 f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
337 ) -> Self {
338 self.context_menu_builder = Some(Rc::new(f));
339 self
340 }
341
342 pub fn on_paste(
356 mut self,
357 handler: impl Fn(&gpui::ClipboardItem, &mut Window, &mut App) -> bool + 'static,
358 ) -> Self {
359 self.paste_handler = Some(Rc::new(handler));
360 self
361 }
362
363 fn render_touch_selection(
370 state: &TextInputState,
371 window: &Window,
372 cx: &App,
373 ) -> Vec<AnyElement> {
374 if state.touch_selection(cx).is_none() {
375 return Vec::new();
376 }
377 let capabilities = state.context_menu_capabilities(cx);
378 let editable = capabilities.is_editable();
379 let copyable = capabilities.is_copyable();
380 let pasteable = editable;
384 let selectable = state.text(cx).len() > 0 && !state.is_all_selected(cx);
385 let focus_handle = state.presentation(cx).focus_handle().clone();
386
387 let dispatch = {
388 let focus_handle = focus_handle.clone();
389 move |action: &dyn gpui::Action, window: &mut Window, cx: &mut App| {
390 focus_handle.dispatch_action(action, window, cx);
391 }
392 };
393 let mut items = Vec::with_capacity(4);
394 if editable && copyable {
395 let dispatch = dispatch.clone();
396 items.push(EditMenuItem::new(t!("Input.Cut"), move |window, cx| {
397 dispatch(&gpui_base::input::Cut, window, cx);
398 }));
399 }
400 if copyable {
401 let dispatch = dispatch.clone();
402 let state = state.clone();
403 items.push(EditMenuItem::new(t!("Input.Copy"), move |window, cx| {
404 dispatch(&gpui_base::input::Copy, window, cx);
405 state.close_edit_menu(cx);
406 }));
407 }
408 if pasteable {
409 let dispatch = dispatch.clone();
410 items.push(EditMenuItem::new(t!("Input.Paste"), move |window, cx| {
411 dispatch(&gpui_base::input::Paste, window, cx);
412 }));
413 }
414 if selectable {
415 let state = state.clone();
416 items.push(EditMenuItem::new(
417 t!("Input.Select All"),
418 move |window, cx| state.select_all_from_edit_menu(window, cx),
419 ));
420 }
421
422 let drag_state = state.clone();
423 let source_state = state.clone();
424 TouchSelectionOverlay::new(
425 ("input-touch-selection", state.entity_id()),
426 move |_, cx| source_state.touch_selection(cx),
427 )
428 .handles(move |edge, phase, position, _, cx| match phase {
429 TouchPhase::Started => drag_state.begin_edge_drag(edge, position, cx),
430 TouchPhase::Moved => drag_state.update_edge_drag(position, cx),
431 TouchPhase::Ended | TouchPhase::Cancelled => drag_state.end_edge_drag(cx),
432 })
433 .items(items)
434 .into_elements(window, cx)
435 }
436
437 fn render_toggle_mask_button(state: &TextInputState, cx: &App) -> impl IntoElement {
438 let masked = state.presentation(cx).is_masked();
439 Button::new("toggle-mask")
440 .icon(if masked {
441 IconName::Eye
442 } else {
443 IconName::EyeOff
444 })
445 .xsmall()
446 .text()
447 .tab_stop(false)
448 .on_click({
449 let state = state.clone();
450 move |_, window, cx| state.toggle_masked(window, cx)
451 })
452 }
453
454 fn handle_accessibility_set_value(
455 state: &TextInputState,
456 data: Option<&gpui::accesskit::ActionData>,
457 window: &mut Window,
458 cx: &mut App,
459 ) {
460 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
461 return;
462 };
463 state.replace_all(value.to_string(), window, cx);
464 }
465
466 fn render_editor(
468 input_state: TextInputState,
469 search_panel: Option<AnyElement>,
470 _: &Window,
471 ) -> impl IntoElement {
472 v_flex().size_full().children(search_panel).child(
473 div()
474 .relative()
475 .flex_1()
476 .child(input_state.into_any_element()),
477 )
478 }
479}
480
481impl Styled for Input {
482 fn style(&mut self) -> &mut StyleRefinement {
483 &mut self.style
484 }
485}
486
487impl RenderOnce for Input {
488 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
489 const LINE_HEIGHT: Rems = Rems(1.25);
490 let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
491 let state = self.state.clone();
492 sync_focused_input_registry(&state, window, cx);
494
495 state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx);
496 state.set_editor_style(
497 gpui_base::input::InputEditorStyle {
498 foreground: cx.theme().foreground,
499 muted_foreground: cx.theme().muted_foreground,
500 background: cx.theme().editor_background(),
501 border: cx.theme().border,
502 selection: cx.theme().selection,
503 caret: cx.theme().caret,
504 diagnostics: gpui_base::input::DiagnosticColors {
505 error: cx.theme().highlight_theme.style.status.error(cx),
506 warning: cx.theme().highlight_theme.style.status.warning(cx),
507 info: cx.theme().highlight_theme.style.status.info(cx),
508 hint: cx.theme().highlight_theme.style.status.hint(cx),
509 },
510 highlight_styles: cx.theme().highlight_theme.clone(),
511 editor_invisible: cx.theme().highlight_theme.style.editor_invisible,
512 editor_active_line: cx.theme().highlight_theme.style.editor_active_line,
513 editor_gutter_background: cx.theme().highlight_theme.style.editor_gutter_background,
514 fold_icon_renderer: Some(Rc::new(|ix, is_folded| {
515 Button::new(("fold-icon", ix))
516 .ghost()
517 .icon(if is_folded {
518 IconName::ChevronRight
519 } else {
520 IconName::ChevronDown
521 })
522 .xsmall()
523 .rounded(ButtonRounded::Small)
524 .size(px(14.))
525 .selected(is_folded)
526 .into_any_element()
527 })),
528 },
529 cx,
530 );
531 state.set_editor_paddings(
532 if state.presentation(cx).is_multi_line() {
533 Edges {
534 top: self.size.input_py(),
535 right: self.size.input_px(),
536 bottom: self.size.input_py(),
537 left: self.size.input_px(),
538 }
539 } else {
540 Edges::default()
541 },
542 cx,
543 );
544 state.set_disabled(self.disabled, cx);
545 state.set_readonly(self.readonly, cx);
546 state.set_text_align(text_align, cx);
547 let custom = self.context_menu_builder.clone();
548 state.on_context_menu(
549 Rc::new(move |_, capabilities, position, window, cx| {
550 let menu = if let Some(custom) = custom.as_ref() {
551 custom(NativeMenu::new(), window, cx)
552 } else {
553 let enabled = !capabilities.is_disabled();
554 let editable = enabled && !capabilities.is_readonly();
557 let mut menu = NativeMenu::new();
558 if capabilities.is_code_editor() {
559 menu = menu
560 .menu_with_disabled(
561 t!("Input.Go to Definition"),
562 !(enabled && capabilities.has_definition()),
563 Box::new(gpui_base::input::GoToDefinition),
564 )
565 .menu_with_disabled(
566 t!("Input.Show Code Actions"),
567 !(editable && capabilities.has_code_actions()),
568 Box::new(gpui_base::input::ToggleCodeActions),
569 )
570 .separator();
571 }
572 menu.menu_with_disabled(
573 t!("Input.Cut"),
574 !(editable && capabilities.is_copyable()),
575 Box::new(gpui_base::input::Cut),
576 )
577 .menu_with_disabled(
578 t!("Input.Copy"),
579 !capabilities.is_copyable(),
580 Box::new(gpui_base::input::Copy),
581 )
582 .menu_with_disabled(
583 t!("Input.Paste"),
584 !(editable && cx.read_from_clipboard().is_some()),
585 Box::new(gpui_base::input::Paste),
586 )
587 .separator()
588 .menu(
589 t!("Input.Select All"),
590 Box::new(gpui_base::input::SelectAll),
591 )
592 };
593 menu.show(position, window, cx);
594 }),
595 cx,
596 );
597 let paste_handler = self
600 .paste_handler
601 .clone()
602 .filter(|_| state.presentation(cx).is_editable());
603 let mut overlays = state.render_overlays(window, cx);
604 overlays
605 .floating
606 .extend(Self::render_touch_selection(&state, window, cx));
607
608 let presentation = state.presentation(cx);
609 let content_type = self.content_type;
610 let disabled = self.disabled;
611 let is_multi_line = presentation.is_multi_line();
612 let accessibility_role = accessibility_role(is_multi_line, content_type, self.role);
613 let accessibility_state = state.clone();
614 let accessibility_value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
617 && exposes_accessibility_value(presentation.is_masked(), content_type))
618 .then(|| state.text(cx).to_string());
619 let input_focused =
620 presentation.focus_handle().is_focused(window) && !presentation.is_disabled();
621 if input_focused {
622 sync_native_content_type(window, content_type, presentation.is_editable());
623 }
624 let frame_focus_handle = window
625 .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| {
626 cx.focus_handle()
627 })
628 .read(cx)
629 .clone();
630 let focused = input_focused
631 || (frame_focus_handle.contains_focused(window, cx) && !presentation.is_disabled());
632
633 let gap_x = match self.size {
634 Size::Small => px(4.),
635 Size::Large => px(8.),
636 _ => px(6.),
637 };
638
639 let (bg, _) = input_style(presentation.is_disabled(), cx);
640 let bg = if presentation.is_code_editor() {
641 cx.theme().editor_background()
642 } else {
643 bg
644 };
645 let bg = if presentation.is_disabled() {
646 bg.opacity(0.5)
647 } else {
648 bg
649 };
650 let prefix = self.prefix;
651 let suffix = self.suffix;
652 let show_clear_button = self.cleanable
653 && presentation.is_editable()
654 && !presentation.is_loading()
655 && state.text(cx).len() > 0
656 && !presentation.is_multi_line();
657 let has_suffix =
658 suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
659
660 let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
661
662 let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
664
665 let aria_label = match self.aria_label {
666 Some(label) => Some(label),
667 None if placeholder_is_mask => None,
668 None => placeholder.clone(),
669 };
670 let id = self
671 .id
672 .unwrap_or_else(|| ("input", state.entity_id()).into());
673 BaseInput::new(id)
674 .focused(focused)
675 .disabled(disabled)
676 .track_focus(&frame_focus_handle)
677 .styles(|styles| {
678 styles.focused(|style| {
679 style.when(
680 self.appearance && self.bordered && self.focus_bordered,
681 |style| style.border_1().border_color(cx.theme().ring),
682 )
683 })
684 })
685 .role(accessibility_role)
686 .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
687 .when_some(aria_label, |this, label| this.aria_label(label))
688 .when_some(placeholder, |this, placeholder| {
689 this.aria_placeholder(placeholder)
690 })
691 .when_some(accessibility_value, |this, value| this.aria_value(value))
692 .when(!disabled, |this| {
693 this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
694 Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
695 })
696 })
697 .flex()
698 .size_full()
699 .line_height(LINE_HEIGHT)
700 .when(!is_multi_line, |this| {
701 this.input_px(self.size).input_py(self.size)
702 })
703 .input_h(self.size)
704 .input_text_size(self.size)
705 .items_center()
706 .when(presentation.is_multi_line(), |this| {
707 this.h_auto()
708 .when_some(self.height, |this, height| this.h(height))
709 })
710 .when(self.appearance, |this| {
711 this.bg(bg)
712 .rounded(cx.theme().radius)
713 .when(self.bordered, |this| {
714 this.border_1().border_color(cx.theme().input)
715 })
716 })
717 .items_center()
718 .gap(gap_x)
719 .refine_style(&self.style)
720 .when(
721 focused && self.appearance && self.bordered && self.focus_bordered,
722 |this| this.focus_ring_style(window, cx),
723 )
724 .children(prefix.map(|p| {
725 div()
726 .when(presentation.is_disabled(), |this| this.opacity(0.5))
727 .child(p)
728 }))
729 .when(presentation.is_multi_line(), |this| {
730 this.child(Self::render_editor(state.clone(), overlays.search, window))
731 })
732 .when(!presentation.is_multi_line(), |this| {
733 this.child(state.clone().into_any_element())
734 })
735 .when(has_suffix, |this| {
736 this.pr(self.size.input_px()).child(
737 h_flex()
738 .id("suffix")
739 .gap(gap_x)
740 .items_center()
741 .cursor_default()
742 .when(presentation.is_disabled(), |this| this.opacity(0.5))
743 .when(presentation.is_loading(), |this| {
744 this.child(Spinner::new().color(cx.theme().muted_foreground))
745 })
746 .when(self.mask_toggle, |this| {
747 this.child(Self::render_toggle_mask_button(&state, cx))
748 })
749 .when(show_clear_button, |this| {
750 this.child(clear_button(cx).on_click({
751 let state = state.clone();
752 move |_, window, cx| {
753 state.clean(window, cx);
754 state.focus(window, cx);
755 }
756 }))
757 })
758 .children(suffix),
759 )
760 })
761 .relative()
762 .children(overlays.floating)
763 .when_some(paste_handler, |this, handler| {
764 this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
765 if let Some(clipboard) = cx.read_from_clipboard() {
766 if handler(&clipboard, window, cx) {
767 cx.stop_propagation();
768 }
769 }
770 })
771 })
772 .render(window, cx)
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::input::AnyInputState;
780
781 #[test]
782 fn content_types_map_to_accessibility_roles() {
783 let cases = [
784 (None, Role::TextInput),
785 (Some(InputContentType::Name), Role::TextInput),
786 (Some(InputContentType::NamePrefix), Role::TextInput),
787 (Some(InputContentType::GivenName), Role::TextInput),
788 (Some(InputContentType::MiddleName), Role::TextInput),
789 (Some(InputContentType::FamilyName), Role::TextInput),
790 (Some(InputContentType::NameSuffix), Role::TextInput),
791 (Some(InputContentType::Nickname), Role::TextInput),
792 (Some(InputContentType::JobTitle), Role::TextInput),
793 (Some(InputContentType::OrganizationName), Role::TextInput),
794 (Some(InputContentType::Location), Role::TextInput),
795 (Some(InputContentType::FullStreetAddress), Role::TextInput),
796 (Some(InputContentType::StreetAddressLine1), Role::TextInput),
797 (Some(InputContentType::StreetAddressLine2), Role::TextInput),
798 (Some(InputContentType::AddressCity), Role::TextInput),
799 (Some(InputContentType::AddressState), Role::TextInput),
800 (Some(InputContentType::AddressCityAndState), Role::TextInput),
801 (Some(InputContentType::Sublocality), Role::TextInput),
802 (Some(InputContentType::CountryName), Role::TextInput),
803 (Some(InputContentType::PostalCode), Role::TextInput),
804 (
805 Some(InputContentType::TelephoneNumber),
806 Role::PhoneNumberInput,
807 ),
808 (Some(InputContentType::EmailAddress), Role::EmailInput),
809 (Some(InputContentType::Url), Role::UrlInput),
810 (Some(InputContentType::CreditCardNumber), Role::TextInput),
811 (Some(InputContentType::CreditCardName), Role::TextInput),
812 (Some(InputContentType::CreditCardGivenName), Role::TextInput),
813 (
814 Some(InputContentType::CreditCardMiddleName),
815 Role::TextInput,
816 ),
817 (
818 Some(InputContentType::CreditCardFamilyName),
819 Role::TextInput,
820 ),
821 (
822 Some(InputContentType::CreditCardSecurityCode),
823 Role::TextInput,
824 ),
825 (
826 Some(InputContentType::CreditCardExpiration),
827 Role::TextInput,
828 ),
829 (
830 Some(InputContentType::CreditCardExpirationMonth),
831 Role::TextInput,
832 ),
833 (
834 Some(InputContentType::CreditCardExpirationYear),
835 Role::TextInput,
836 ),
837 (Some(InputContentType::CreditCardType), Role::TextInput),
838 (Some(InputContentType::Username), Role::TextInput),
839 (Some(InputContentType::Password), Role::PasswordInput),
840 (Some(InputContentType::NewPassword), Role::PasswordInput),
841 (Some(InputContentType::OneTimeCode), Role::TextInput),
842 (
843 Some(InputContentType::ShipmentTrackingNumber),
844 Role::TextInput,
845 ),
846 (Some(InputContentType::FlightNumber), Role::TextInput),
847 (Some(InputContentType::DateTime), Role::DateTimeInput),
848 (Some(InputContentType::Birthdate), Role::DateInput),
849 (Some(InputContentType::BirthdateDay), Role::TextInput),
850 (Some(InputContentType::BirthdateMonth), Role::TextInput),
851 (Some(InputContentType::BirthdateYear), Role::TextInput),
852 (Some(InputContentType::CellularEid), Role::TextInput),
853 (Some(InputContentType::CellularImei), Role::TextInput),
854 ];
855
856 for (content_type, role) in cases {
857 assert_eq!(
858 accessibility_role(false, content_type, RoleOverride::Implicit),
859 Some(role)
860 );
861 }
862 }
863
864 #[test]
865 fn multiline_inputs_keep_multiline_accessibility_role() {
866 assert_eq!(
867 accessibility_role(
868 true,
869 Some(InputContentType::Password),
870 RoleOverride::Implicit
871 ),
872 Some(Role::MultilineTextInput)
873 );
874 }
875
876 #[test]
877 fn explicit_accessibility_role_overrides_defaults() {
878 assert_eq!(
879 accessibility_role(
880 false,
881 Some(InputContentType::Password),
882 Role::TextInput.into()
883 ),
884 Some(Role::TextInput)
885 );
886 assert_eq!(
887 accessibility_role(
888 true,
889 Some(InputContentType::Password),
890 Role::TextInput.into()
891 ),
892 Some(Role::TextInput)
893 );
894 }
895
896 #[test]
897 fn presentational_role_emits_no_accessibility_node() {
898 assert_eq!(
899 accessibility_role(
900 false,
901 Some(InputContentType::Password),
902 RoleOverride::Presentational
903 ),
904 None
905 );
906 assert_eq!(
907 accessibility_role(true, None, RoleOverride::Presentational),
908 None
909 );
910 }
911
912 #[test]
913 fn role_option_converts_to_the_matching_override() {
914 assert_eq!(
915 RoleOverride::from(Some(Role::Button)),
916 RoleOverride::Role(Role::Button)
917 );
918 assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
919 }
920
921 #[gpui::test]
922 fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
923 use gpui::{AppContext as _, Render};
924
925 struct Probe;
926 impl Render for Probe {
927 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
928 div()
929 }
930 }
931
932 cx.update(crate::init);
933 let _ = cx.add_window_view(|window, cx| {
934 let state = cx.new(|cx| InputState::new(window, cx));
935
936 assert!(Input::new(&state).paste_handler.is_none());
937 let input = Input::new(&state).on_paste(|_, _, _| true);
938 assert!(input.paste_handler.is_some());
939 Probe
940 });
941 }
942
943 #[gpui::test]
944 fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
945 use crate::ElementExt as _;
946 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
947 use std::sync::{Arc, Mutex};
948
949 type EmittedState = Option<(Option<String>, bool)>;
950
951 struct InputA11yProbe {
952 state: Entity<InputState>,
953 emitted: Arc<Mutex<EmittedState>>,
954 }
955
956 impl Render for InputA11yProbe {
957 fn render(
958 &mut self,
959 _window: &mut Window,
960 _cx: &mut gpui::Context<Self>,
961 ) -> impl IntoElement {
962 let state = self.state.clone();
963 let emitted = self.emitted.clone();
964 div().on_prepaint(move |_, window, cx| {
965 let input = Input::new(&state).render(window, cx).into_element();
966 let mut node = gpui::accesskit::Node::new(Role::TextInput);
967 input.write_a11y_info(&mut node);
968 *emitted.lock().unwrap() = Some((
969 node.value().map(ToOwned::to_owned),
970 node.supports_action(AccessibleAction::SetValue),
971 ));
972 })
973 }
974 }
975
976 cx.update(crate::init);
977 let emitted = Arc::new(Mutex::new(None));
978 let captured = emitted.clone();
979 let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
980 state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
981 emitted,
982 });
983 cx.update(|window, cx| {
984 let _ = window.draw(cx);
985 });
986 let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
989 assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
990
991 let state = probe.read_with(cx, |probe, _| probe.state.clone());
992 let base: TextInputState = state.clone().into();
993 cx.update(|window, cx| {
994 Input::handle_accessibility_set_value(&base, None, window, cx);
995 });
996 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
997
998 let action = gpui::accesskit::ActionData::Value("updated".into());
999 cx.update(|window, cx| {
1000 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1001 });
1002 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated");
1003 }
1004
1005 #[gpui::test]
1006 fn input_emits_accessibility_id(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 EmittedIds = Vec<Option<String>>;
1012
1013 struct InputA11yProbe {
1014 state: Entity<InputState>,
1015 emitted: Arc<Mutex<EmittedIds>>,
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().on_prepaint(move |_, window, cx| {
1027 let mut author_id_of = |input: Input| {
1028 let mut node = gpui::accesskit::Node::new(Role::TextInput);
1029 input
1030 .render(window, cx)
1031 .into_element()
1032 .write_a11y_info(&mut node);
1033 node.author_id().map(ToOwned::to_owned)
1034 };
1035
1036 *emitted.lock().unwrap() = vec![
1037 author_id_of(Input::new(&state)),
1038 author_id_of(Input::new(&state).accessibility_id("search.query")),
1039 ];
1040 })
1041 }
1042 }
1043
1044 cx.update(crate::init);
1045 let emitted = Arc::new(Mutex::new(Vec::new()));
1046 let captured = emitted.clone();
1047 let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1048 state: cx.new(|cx| InputState::new(window, cx)),
1049 emitted,
1050 });
1051 cx.update(|window, cx| {
1052 let _ = window.draw(cx);
1053 });
1054
1055 assert_eq!(
1056 *captured.lock().unwrap(),
1057 vec![None, Some("search.query".into())]
1058 );
1059 }
1060
1061 #[test]
1062 fn accessibility_value_is_hidden_for_secret_inputs() {
1063 assert!(exposes_accessibility_value(false, None));
1064 assert!(!exposes_accessibility_value(true, None));
1065 assert!(!exposes_accessibility_value(
1066 false,
1067 Some(InputContentType::Password)
1068 ));
1069 assert!(!exposes_accessibility_value(
1070 false,
1071 Some(InputContentType::NewPassword)
1072 ));
1073 }
1074
1075 #[gpui::test]
1076 fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1077 use crate::{Root, WindowExt as _};
1078 use gpui::{AppContext as _, Render};
1079
1080 struct Probe {
1081 input: Entity<InputState>,
1082 textarea: Entity<crate::input::TextareaState>,
1083 editor: Entity<crate::input::EditorState>,
1084 otp: Entity<gpui_base::OtpState>,
1085 other: gpui::FocusHandle,
1086 }
1087 impl Render for Probe {
1088 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1089 div()
1090 .child(div().track_focus(&self.other))
1091 .child(Input::new(&self.input))
1092 .child(crate::input::Textarea::new(&self.textarea))
1093 .child(crate::input::Editor::new(&self.editor))
1094 .child(crate::input::OtpInput::new(&self.otp))
1095 }
1096 }
1097
1098 cx.update(crate::init);
1099 let mut input = None;
1100 let mut textarea = None;
1101 let mut editor = None;
1102 let mut other_focus = None;
1103 let mut otp = None;
1104 let window = cx.update(|cx| {
1105 cx.open_window(Default::default(), |window, cx| {
1106 let state = cx.new(|cx| InputState::new(window, cx));
1107 let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1108 let editor_state =
1109 cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1110 let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1111 input = Some(state.clone());
1112 textarea = Some(textarea_state.clone());
1113 editor = Some(editor_state.clone());
1114 otp = Some(otp_state.clone());
1115 let other = cx.focus_handle();
1116 other_focus = Some(other.clone());
1117 let probe = cx.new(|_| Probe {
1118 input: state,
1119 textarea: textarea_state,
1120 editor: editor_state,
1121 otp: otp_state,
1122 other,
1123 });
1124 cx.new(|cx| Root::new(probe, window, cx))
1125 })
1126 .unwrap()
1127 });
1128 let input = input.unwrap();
1129 let textarea = textarea.unwrap();
1130 let editor = editor.unwrap();
1131 let otp = otp.unwrap();
1132 let other_focus = other_focus.unwrap();
1133 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1134
1135 let cases: Vec<AnyInputState> = vec![
1137 input.clone().into(),
1138 textarea.clone().into(),
1139 editor.clone().into(),
1140 otp.clone().into(),
1141 ];
1142 for expected in cases {
1143 cx.update(|window, cx| {
1144 let _ = window.draw(cx);
1145 });
1146 cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1147 cx.run_until_parked();
1148 cx.update(|window, cx| {
1149 let _ = window.draw(cx);
1150 });
1151 assert_eq!(
1152 cx.update(|window, cx| window.focused_input(cx)),
1153 Some(expected)
1154 );
1155
1156 cx.update(|window, cx| other_focus.clone().focus(window, cx));
1157 cx.run_until_parked();
1158 cx.update(|window, cx| {
1159 let _ = window.draw(cx);
1160 });
1161 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1162 }
1163 }
1164
1165 #[gpui::test]
1166 fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1167 use crate::{Root, WindowExt as _};
1168 use gpui::{AppContext as _, Render};
1169
1170 struct Probe {
1171 input: Entity<InputState>,
1172 show_input: bool,
1173 other: gpui::FocusHandle,
1174 }
1175 impl Render for Probe {
1176 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1177 let base = div().child(div().track_focus(&self.other));
1178 if self.show_input {
1179 base.child(Input::new(&self.input))
1180 } else {
1181 base
1182 }
1183 }
1184 }
1185
1186 cx.update(crate::init);
1187 let mut input = None;
1188 let mut other_focus = None;
1189 let mut probe_entity = None;
1190 let window = cx.update(|cx| {
1191 cx.open_window(Default::default(), |window, cx| {
1192 let state = cx.new(|cx| InputState::new(window, cx));
1193 input = Some(state.clone());
1194 let other = cx.focus_handle();
1195 other_focus = Some(other.clone());
1196 let probe = cx.new(|_| Probe {
1197 input: state,
1198 show_input: true,
1199 other,
1200 });
1201 probe_entity = Some(probe.clone());
1202 cx.new(|cx| Root::new(probe, window, cx))
1203 })
1204 .unwrap()
1205 });
1206 let input: AnyInputState = input.unwrap().into();
1207 let other_focus = other_focus.unwrap();
1208 let probe = probe_entity.unwrap();
1209 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1210
1211 cx.update(|window, cx| {
1212 let _ = window.draw(cx);
1213 });
1214 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1215 cx.run_until_parked();
1216 cx.update(|window, cx| {
1217 let _ = window.draw(cx);
1218 });
1219 assert_eq!(
1220 cx.update(|window, cx| window.focused_input(cx)),
1221 Some(input.clone())
1222 );
1223
1224 cx.update(|window, cx| {
1228 probe.update(cx, |probe, cx| {
1229 probe.show_input = false;
1230 cx.notify();
1231 });
1232 other_focus.focus(window, cx);
1233 });
1234 cx.run_until_parked();
1235 cx.update(|window, cx| {
1236 let _ = window.draw(cx);
1237 });
1238 assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1239 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1240 }
1241}