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