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