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, MouseButton, ParentElement as _, Rems, RenderOnce, Role,
7 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, TouchPhase,
8 Window, div, 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 = !presentation.is_disabled()
698 && (state.has_selection_focus(window, cx)
699 || frame_focus_handle.contains_focused(window, cx));
700
701 let gap_x = match self.size {
702 Size::Small => px(4.),
703 Size::Large => px(8.),
704 _ => px(6.),
705 };
706
707 let (bg, _) = input_style(presentation.is_disabled(), cx);
708 let bg = if presentation.is_code_editor() {
709 cx.theme().editor_background()
710 } else {
711 bg
712 };
713 let bg = if presentation.is_disabled() {
714 bg.opacity(0.5)
715 } else {
716 bg
717 };
718 let prefix = self.prefix;
719 let suffix = self.suffix;
720 let show_clear_button = self.cleanable
721 && presentation.is_editable()
722 && !presentation.is_loading()
723 && state.text(cx).len() > 0
724 && !presentation.is_multi_line();
725 let has_suffix =
726 suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
727
728 let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
729
730 let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
732
733 let aria_label = match self.aria_label {
734 Some(label) => Some(label),
735 None if placeholder_is_mask => None,
736 None => placeholder.clone(),
737 };
738 let id = self
739 .id
740 .unwrap_or_else(|| ("input", state.entity_id()).into());
741 BaseInput::new(id)
742 .focused(focused)
743 .disabled(disabled)
744 .track_focus(&frame_focus_handle)
745 .on_mouse_down(MouseButton::Right, {
746 let state = state.clone();
747 move |_, window, cx| {
748 sync_focused_input_registry(&state, window, cx);
752 }
753 })
754 .when(disabled, |this| {
755 this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
756 })
757 .styles(|styles| {
758 styles.focused(|style| {
759 style.when(
760 self.appearance && self.bordered && self.focus_bordered,
761 |style| style.border_1().border_color(cx.theme().ring),
762 )
763 })
764 })
765 .role(accessibility_role)
766 .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
767 .when_some(aria_label, |this, label| this.aria_label(label))
768 .when_some(placeholder, |this, placeholder| {
769 this.aria_placeholder(placeholder)
770 })
771 .when_some(accessibility_value, |this, value| this.aria_value(value))
772 .on_a11y_action(AccessibleAction::Focus, {
773 let state = state.clone();
774 move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx)
775 })
776 .when(presentation.is_editable(), |this| {
777 this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
778 Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
779 })
780 })
781 .flex()
782 .size_full()
783 .line_height(LINE_HEIGHT)
784 .when(!is_multi_line, |this| {
785 this.input_px(self.size).input_py(self.size)
786 })
787 .input_h(self.size)
788 .input_text_size(self.size)
789 .items_center()
790 .when(presentation.is_multi_line(), |this| {
791 this.h_auto()
792 .when_some(self.height, |this, height| this.h(height))
793 })
794 .when(self.appearance, |this| {
795 this.bg(bg)
796 .rounded(cx.theme().radius)
797 .when(self.bordered, |this| {
798 this.border_1().border_color(cx.theme().input)
799 })
800 })
801 .items_center()
802 .gap(gap_x)
803 .refine_style(&self.style)
804 .when(
805 focused && self.appearance && self.bordered && self.focus_bordered,
806 |this| this.focus_ring_style(window, cx),
807 )
808 .children(prefix.map(|p| {
809 div()
810 .when(presentation.is_disabled(), |this| this.opacity(0.5))
811 .child(p)
812 }))
813 .when(presentation.is_multi_line(), |this| {
814 this.child(Self::render_editor(state.clone(), overlays.search, window))
815 })
816 .when(!presentation.is_multi_line(), |this| {
817 this.child(state.clone().into_any_element())
818 })
819 .when(has_suffix, |this| {
820 this.pr(self.size.input_px()).child(
821 h_flex()
822 .id("suffix")
823 .gap(gap_x)
824 .items_center()
825 .cursor_default()
826 .when(presentation.is_disabled(), |this| this.opacity(0.5))
827 .when(presentation.is_loading(), |this| {
828 this.child(Spinner::new().color(cx.theme().muted_foreground))
829 })
830 .when(self.mask_toggle, |this| {
831 this.child(Self::render_toggle_mask_button(&state, cx))
832 })
833 .when(show_clear_button, |this| {
834 this.child(clear_button(cx).on_click({
835 let state = state.clone();
836 move |_, window, cx| {
837 state.clean(window, cx);
838 state.focus(window, cx);
839 }
840 }))
841 })
842 .children(suffix),
843 )
844 })
845 .relative()
846 .children(overlays.floating)
847 .when_some(paste_handler, |this, handler| {
848 this.capture_action(move |_: &gpui_base::input::Paste, window, cx| {
849 if let Some(clipboard) = cx.read_from_clipboard() {
850 if handler(&clipboard, window, cx) {
851 cx.stop_propagation();
852 }
853 }
854 })
855 })
856 .render(window, cx)
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use crate::input::AnyInputState;
864
865 #[test]
866 fn content_types_map_to_accessibility_roles() {
867 let cases = [
868 (None, Role::TextInput),
869 (Some(InputContentType::Name), Role::TextInput),
870 (Some(InputContentType::NamePrefix), Role::TextInput),
871 (Some(InputContentType::GivenName), Role::TextInput),
872 (Some(InputContentType::MiddleName), Role::TextInput),
873 (Some(InputContentType::FamilyName), Role::TextInput),
874 (Some(InputContentType::NameSuffix), Role::TextInput),
875 (Some(InputContentType::Nickname), Role::TextInput),
876 (Some(InputContentType::JobTitle), Role::TextInput),
877 (Some(InputContentType::OrganizationName), Role::TextInput),
878 (Some(InputContentType::Location), Role::TextInput),
879 (Some(InputContentType::FullStreetAddress), Role::TextInput),
880 (Some(InputContentType::StreetAddressLine1), Role::TextInput),
881 (Some(InputContentType::StreetAddressLine2), Role::TextInput),
882 (Some(InputContentType::AddressCity), Role::TextInput),
883 (Some(InputContentType::AddressState), Role::TextInput),
884 (Some(InputContentType::AddressCityAndState), Role::TextInput),
885 (Some(InputContentType::Sublocality), Role::TextInput),
886 (Some(InputContentType::CountryName), Role::TextInput),
887 (Some(InputContentType::PostalCode), Role::TextInput),
888 (
889 Some(InputContentType::TelephoneNumber),
890 Role::PhoneNumberInput,
891 ),
892 (Some(InputContentType::EmailAddress), Role::EmailInput),
893 (Some(InputContentType::Url), Role::UrlInput),
894 (Some(InputContentType::CreditCardNumber), Role::TextInput),
895 (Some(InputContentType::CreditCardName), Role::TextInput),
896 (Some(InputContentType::CreditCardGivenName), Role::TextInput),
897 (
898 Some(InputContentType::CreditCardMiddleName),
899 Role::TextInput,
900 ),
901 (
902 Some(InputContentType::CreditCardFamilyName),
903 Role::TextInput,
904 ),
905 (
906 Some(InputContentType::CreditCardSecurityCode),
907 Role::TextInput,
908 ),
909 (
910 Some(InputContentType::CreditCardExpiration),
911 Role::TextInput,
912 ),
913 (
914 Some(InputContentType::CreditCardExpirationMonth),
915 Role::TextInput,
916 ),
917 (
918 Some(InputContentType::CreditCardExpirationYear),
919 Role::TextInput,
920 ),
921 (Some(InputContentType::CreditCardType), Role::TextInput),
922 (Some(InputContentType::Username), Role::TextInput),
923 (Some(InputContentType::Password), Role::PasswordInput),
924 (Some(InputContentType::NewPassword), Role::PasswordInput),
925 (Some(InputContentType::OneTimeCode), Role::TextInput),
926 (
927 Some(InputContentType::ShipmentTrackingNumber),
928 Role::TextInput,
929 ),
930 (Some(InputContentType::FlightNumber), Role::TextInput),
931 (Some(InputContentType::DateTime), Role::DateTimeInput),
932 (Some(InputContentType::Birthdate), Role::DateInput),
933 (Some(InputContentType::BirthdateDay), Role::TextInput),
934 (Some(InputContentType::BirthdateMonth), Role::TextInput),
935 (Some(InputContentType::BirthdateYear), Role::TextInput),
936 (Some(InputContentType::CellularEid), Role::TextInput),
937 (Some(InputContentType::CellularImei), Role::TextInput),
938 ];
939
940 for (content_type, role) in cases {
941 assert_eq!(
942 accessibility_role(false, content_type, RoleOverride::Implicit),
943 Some(role)
944 );
945 }
946 }
947
948 #[test]
949 fn multiline_inputs_keep_multiline_accessibility_role() {
950 assert_eq!(
951 accessibility_role(
952 true,
953 Some(InputContentType::Password),
954 RoleOverride::Implicit
955 ),
956 Some(Role::MultilineTextInput)
957 );
958 }
959
960 #[test]
961 fn explicit_accessibility_role_overrides_defaults() {
962 assert_eq!(
963 accessibility_role(
964 false,
965 Some(InputContentType::Password),
966 Role::TextInput.into()
967 ),
968 Some(Role::TextInput)
969 );
970 assert_eq!(
971 accessibility_role(
972 true,
973 Some(InputContentType::Password),
974 Role::TextInput.into()
975 ),
976 Some(Role::TextInput)
977 );
978 }
979
980 #[test]
981 fn presentational_role_emits_no_accessibility_node() {
982 assert_eq!(
983 accessibility_role(
984 false,
985 Some(InputContentType::Password),
986 RoleOverride::Presentational
987 ),
988 None
989 );
990 assert_eq!(
991 accessibility_role(true, None, RoleOverride::Presentational),
992 None
993 );
994 }
995
996 #[test]
997 fn role_option_converts_to_the_matching_override() {
998 assert_eq!(
999 RoleOverride::from(Some(Role::Button)),
1000 RoleOverride::Role(Role::Button)
1001 );
1002 assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
1003 }
1004
1005 #[gpui::test]
1006 fn test_on_paste_builder(cx: &mut gpui::TestAppContext) {
1007 use gpui::{AppContext as _, Render};
1008
1009 struct Probe;
1010 impl Render for Probe {
1011 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1012 div()
1013 }
1014 }
1015
1016 cx.update(crate::init);
1017 let _ = cx.add_window_view(|window, cx| {
1018 let state = cx.new(|cx| InputState::new(window, cx));
1019
1020 assert!(Input::new(&state).paste_handler.is_none());
1021 let input = Input::new(&state).on_paste(|_, _, _| true);
1022 assert!(input.paste_handler.is_some());
1023 Probe
1024 });
1025 }
1026
1027 #[gpui::test]
1028 fn test_on_token_hover_builder(cx: &mut gpui::TestAppContext) {
1029 use gpui::{AppContext as _, Render};
1030
1031 struct Probe;
1032 impl Render for Probe {
1033 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1034 div()
1035 }
1036 }
1037
1038 cx.update(crate::init);
1039 let _ = cx.add_window_view(|window, cx| {
1040 let state = cx.new(|cx| InputState::new(window, cx));
1041
1042 assert!(Input::new(&state).token_hover_listener.is_none());
1043 let input = Input::new(&state).on_token_hover(|_, _, _| {});
1044 assert!(input.token_hover_listener.is_some());
1045 Probe
1046 });
1047 }
1048
1049 #[gpui::test]
1050 fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
1051 use crate::ElementExt as _;
1052 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1053 use std::sync::{Arc, Mutex};
1054
1055 type EmittedState = Option<(Option<String>, bool)>;
1056
1057 struct InputA11yProbe {
1058 state: Entity<InputState>,
1059 emitted: Arc<Mutex<EmittedState>>,
1060 }
1061
1062 impl Render for InputA11yProbe {
1063 fn render(
1064 &mut self,
1065 _window: &mut Window,
1066 _cx: &mut gpui::Context<Self>,
1067 ) -> impl IntoElement {
1068 let state = self.state.clone();
1069 let emitted = self.emitted.clone();
1070 div()
1071 .child(Input::new(&state))
1072 .on_prepaint(move |_, window, cx| {
1073 let input = Input::new(&state).render(window, cx).into_element();
1074 let mut node = gpui::accesskit::Node::new(Role::TextInput);
1075 input.write_a11y_info(&mut node);
1076 *emitted.lock().unwrap() = Some((
1077 node.value().map(ToOwned::to_owned),
1078 node.supports_action(AccessibleAction::SetValue),
1079 ));
1080 })
1081 }
1082 }
1083
1084 cx.update(crate::init);
1085 let emitted = Arc::new(Mutex::new(None));
1086 let captured = emitted.clone();
1087 let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1088 state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
1089 emitted,
1090 });
1091 cx.update(|window, cx| {
1092 let _ = window.draw(cx);
1093 });
1094 let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
1097 assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
1098
1099 let state = probe.read_with(cx, |probe, _| probe.state.clone());
1100 let base: TextInputState = state.clone().into();
1101 cx.update(|window, cx| {
1102 Input::handle_accessibility_set_value(&base, None, window, cx);
1103 Input::handle_accessibility_focus(&base, window, cx);
1104 assert!(base.presentation(cx).focus_handle().is_focused(window));
1105 window.draw(cx).clear(cx);
1106 });
1107 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1108
1109 let changes = std::rc::Rc::new(std::cell::Cell::new(0));
1110 let observed = changes.clone();
1111 let _subscription = cx.update(|_, cx| {
1112 cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| {
1113 if matches!(event, super::super::InputEvent::Change) {
1114 observed.set(observed.get() + 1);
1115 }
1116 })
1117 });
1118 let action = gpui::accesskit::ActionData::Value("updated🦀".into());
1119 cx.update(|window, cx| {
1120 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1121 });
1122 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1123 assert_eq!(changes.get(), 1);
1124 for disabled in [false, true] {
1125 cx.update(|window, cx| {
1126 base.set_disabled(disabled, cx);
1127 base.set_readonly(!disabled, cx);
1128 window.blur(cx);
1129 Input::handle_accessibility_focus(&base, window, cx);
1130 assert_eq!(
1131 base.presentation(cx).focus_handle().is_focused(window),
1132 !disabled
1133 );
1134 let action = gpui::accesskit::ActionData::Value("rejected".into());
1135 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1136 });
1137 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1138 assert_eq!(changes.get(), 1);
1139 }
1140 cx.update(|window, cx| {
1141 base.set_disabled(false, cx);
1142 base.set_readonly(false, cx);
1143 Input::handle_accessibility_focus(&base, window, cx);
1144 window.draw(cx).clear(cx);
1145 window.dispatch_action(Box::new(super::super::Undo), cx);
1146 });
1147 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
1148 cx.update(|window, cx| {
1149 state.update(cx, |state, cx| state.set_masked(true, window, cx));
1150 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
1151 window.draw(cx).clear(cx);
1152 });
1153 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀");
1154 assert_eq!(*captured.lock().unwrap(), Some((None, true)));
1155 }
1156
1157 #[gpui::test]
1158 fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) {
1159 use gpui::{AppContext as _, Render};
1160
1161 struct Probe(Entity<crate::input::EditorState>);
1162
1163 impl Render for Probe {
1164 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1165 div().child(crate::input::Editor::new(&self.0))
1166 }
1167 }
1168
1169 cx.update(crate::init);
1170 let (probe, cx) = cx.add_window_view(|window, cx| {
1171 Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust")))
1172 });
1173 let editor = probe.read_with(cx, |probe, _| probe.0.clone());
1174 let state: TextInputState = editor.clone().into();
1175 let action = gpui::accesskit::ActionData::Value("(".into());
1176
1177 cx.update(|window, cx| {
1178 Input::handle_accessibility_set_value(&state, Some(&action), window, cx)
1179 });
1180 assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "(");
1181 }
1182
1183 #[gpui::test]
1184 fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
1185 use crate::ElementExt as _;
1186 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
1187 use std::sync::{Arc, Mutex};
1188
1189 type EmittedIds = Vec<Option<String>>;
1190
1191 struct InputA11yProbe {
1192 state: Entity<InputState>,
1193 emitted: Arc<Mutex<EmittedIds>>,
1194 }
1195
1196 impl Render for InputA11yProbe {
1197 fn render(
1198 &mut self,
1199 _window: &mut Window,
1200 _cx: &mut gpui::Context<Self>,
1201 ) -> impl IntoElement {
1202 let state = self.state.clone();
1203 let emitted = self.emitted.clone();
1204 div().on_prepaint(move |_, window, cx| {
1205 let mut author_id_of = |input: Input| {
1206 let mut node = gpui::accesskit::Node::new(Role::TextInput);
1207 input
1208 .render(window, cx)
1209 .into_element()
1210 .write_a11y_info(&mut node);
1211 node.author_id().map(ToOwned::to_owned)
1212 };
1213
1214 *emitted.lock().unwrap() = vec![
1215 author_id_of(Input::new(&state)),
1216 author_id_of(Input::new(&state).accessibility_id("search.query")),
1217 ];
1218 })
1219 }
1220 }
1221
1222 cx.update(crate::init);
1223 let emitted = Arc::new(Mutex::new(Vec::new()));
1224 let captured = emitted.clone();
1225 let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
1226 state: cx.new(|cx| InputState::new(window, cx)),
1227 emitted,
1228 });
1229 cx.update(|window, cx| {
1230 let _ = window.draw(cx);
1231 });
1232
1233 assert_eq!(
1234 *captured.lock().unwrap(),
1235 vec![None, Some("search.query".into())]
1236 );
1237 }
1238
1239 #[test]
1240 fn accessibility_value_is_hidden_for_secret_inputs() {
1241 assert!(exposes_accessibility_value(false, None));
1242 assert!(!exposes_accessibility_value(true, None));
1243 assert!(!exposes_accessibility_value(
1244 false,
1245 Some(InputContentType::Password)
1246 ));
1247 assert!(!exposes_accessibility_value(
1248 false,
1249 Some(InputContentType::NewPassword)
1250 ));
1251 }
1252
1253 #[gpui::test]
1254 fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
1255 use crate::{Root, WindowExt as _};
1256 use gpui::{AppContext as _, Render};
1257
1258 struct Probe {
1259 input: Entity<InputState>,
1260 textarea: Entity<crate::input::TextareaState>,
1261 editor: Entity<crate::input::EditorState>,
1262 otp: Entity<gpui_base::OtpState>,
1263 other: gpui::FocusHandle,
1264 }
1265 impl Render for Probe {
1266 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1267 div()
1268 .child(div().track_focus(&self.other))
1269 .child(Input::new(&self.input))
1270 .child(crate::input::Textarea::new(&self.textarea))
1271 .child(crate::input::Editor::new(&self.editor))
1272 .child(crate::input::OtpInput::new(&self.otp))
1273 }
1274 }
1275
1276 cx.update(crate::init);
1277 let mut input = None;
1278 let mut textarea = None;
1279 let mut editor = None;
1280 let mut other_focus = None;
1281 let mut otp = None;
1282 let window = cx.update(|cx| {
1283 cx.open_window(Default::default(), |window, cx| {
1284 let state = cx.new(|cx| InputState::new(window, cx));
1285 let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
1286 let editor_state =
1287 cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
1288 let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
1289 input = Some(state.clone());
1290 textarea = Some(textarea_state.clone());
1291 editor = Some(editor_state.clone());
1292 otp = Some(otp_state.clone());
1293 let other = cx.focus_handle();
1294 other_focus = Some(other.clone());
1295 let probe = cx.new(|_| Probe {
1296 input: state,
1297 textarea: textarea_state,
1298 editor: editor_state,
1299 otp: otp_state,
1300 other,
1301 });
1302 cx.new(|cx| Root::new(probe, window, cx))
1303 })
1304 .unwrap()
1305 });
1306 let input = input.unwrap();
1307 let textarea = textarea.unwrap();
1308 let editor = editor.unwrap();
1309 let otp = otp.unwrap();
1310 let other_focus = other_focus.unwrap();
1311 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1312
1313 let cases: Vec<AnyInputState> = vec![
1315 input.clone().into(),
1316 textarea.clone().into(),
1317 editor.clone().into(),
1318 otp.clone().into(),
1319 ];
1320 for expected in cases {
1321 cx.update(|window, cx| {
1322 let _ = window.draw(cx);
1323 });
1324 cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
1325 cx.run_until_parked();
1326 cx.update(|window, cx| {
1327 let _ = window.draw(cx);
1328 });
1329 assert_eq!(
1330 cx.update(|window, cx| window.focused_input(cx)),
1331 Some(expected)
1332 );
1333
1334 cx.update(|window, cx| other_focus.clone().focus(window, cx));
1335 cx.run_until_parked();
1336 cx.update(|window, cx| {
1337 let _ = window.draw(cx);
1338 });
1339 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1340 }
1341 }
1342
1343 #[gpui::test]
1344 fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1345 use crate::{Root, WindowExt as _};
1346 use gpui::{AppContext as _, Render};
1347
1348 struct Probe {
1349 input: Entity<InputState>,
1350 show_input: bool,
1351 other: gpui::FocusHandle,
1352 }
1353 impl Render for Probe {
1354 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1355 let base = div().child(div().track_focus(&self.other));
1356 if self.show_input {
1357 base.child(Input::new(&self.input))
1358 } else {
1359 base
1360 }
1361 }
1362 }
1363
1364 cx.update(crate::init);
1365 let mut input = None;
1366 let mut other_focus = None;
1367 let mut probe_entity = None;
1368 let window = cx.update(|cx| {
1369 cx.open_window(Default::default(), |window, cx| {
1370 let state = cx.new(|cx| InputState::new(window, cx));
1371 input = Some(state.clone());
1372 let other = cx.focus_handle();
1373 other_focus = Some(other.clone());
1374 let probe = cx.new(|_| Probe {
1375 input: state,
1376 show_input: true,
1377 other,
1378 });
1379 probe_entity = Some(probe.clone());
1380 cx.new(|cx| Root::new(probe, window, cx))
1381 })
1382 .unwrap()
1383 });
1384 let input: AnyInputState = input.unwrap().into();
1385 let other_focus = other_focus.unwrap();
1386 let probe = probe_entity.unwrap();
1387 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1388
1389 cx.update(|window, cx| {
1390 let _ = window.draw(cx);
1391 });
1392 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1393 cx.run_until_parked();
1394 cx.update(|window, cx| {
1395 let _ = window.draw(cx);
1396 });
1397 assert_eq!(
1398 cx.update(|window, cx| window.focused_input(cx)),
1399 Some(input.clone())
1400 );
1401
1402 cx.update(|window, cx| {
1406 probe.update(cx, |probe, cx| {
1407 probe.show_input = false;
1408 cx.notify();
1409 });
1410 other_focus.focus(window, cx);
1411 });
1412 cx.run_until_parked();
1413 cx.update(|window, cx| {
1414 let _ = window.draw(cx);
1415 });
1416 assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1417 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1418 }
1419}