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