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, 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::{ActiveTheme, Colorize, v_flex};
15use crate::{IconName, Size};
16use crate::{RoleOverride, Selectable, StyledExt, h_flex};
17use crate::{Sizable, StyleSized};
18use gpui_base::InputBase as BaseInput;
19use rust_i18n::t;
20
21use super::state::{TextInputState, sync_focused_input_registry};
22use super::{InputContentType, InputState, sync_native_content_type};
23use crate::ThemeStyled as _;
24
25fn accessibility_role(
26 is_multi_line: bool,
27 content_type: Option<InputContentType>,
28 role: RoleOverride,
29) -> Option<Role> {
30 role.resolve(|| {
31 if is_multi_line {
32 return Role::MultilineTextInput;
33 }
34
35 match content_type {
36 None => Role::TextInput,
37 Some(InputContentType::TelephoneNumber) => Role::PhoneNumberInput,
38 Some(InputContentType::EmailAddress) => Role::EmailInput,
39 Some(InputContentType::Url) => Role::UrlInput,
40 Some(InputContentType::Password | InputContentType::NewPassword) => Role::PasswordInput,
41 Some(InputContentType::DateTime) => Role::DateTimeInput,
42 Some(InputContentType::Birthdate) => Role::DateInput,
43 Some(
44 InputContentType::Name
45 | InputContentType::NamePrefix
46 | InputContentType::GivenName
47 | InputContentType::MiddleName
48 | InputContentType::FamilyName
49 | InputContentType::NameSuffix
50 | InputContentType::Nickname
51 | InputContentType::JobTitle
52 | InputContentType::OrganizationName
53 | InputContentType::Location
54 | InputContentType::FullStreetAddress
55 | InputContentType::StreetAddressLine1
56 | InputContentType::StreetAddressLine2
57 | InputContentType::AddressCity
58 | InputContentType::AddressState
59 | InputContentType::AddressCityAndState
60 | InputContentType::Sublocality
61 | InputContentType::CountryName
62 | InputContentType::PostalCode
63 | InputContentType::CreditCardNumber
64 | InputContentType::CreditCardName
65 | InputContentType::CreditCardGivenName
66 | InputContentType::CreditCardMiddleName
67 | InputContentType::CreditCardFamilyName
68 | InputContentType::CreditCardSecurityCode
69 | InputContentType::CreditCardExpiration
70 | InputContentType::CreditCardExpirationMonth
71 | InputContentType::CreditCardExpirationYear
72 | InputContentType::CreditCardType
73 | InputContentType::Username
74 | InputContentType::OneTimeCode
75 | InputContentType::ShipmentTrackingNumber
76 | InputContentType::FlightNumber
77 | InputContentType::BirthdateDay
78 | InputContentType::BirthdateMonth
79 | InputContentType::BirthdateYear
80 | InputContentType::CellularEid
81 | InputContentType::CellularImei,
82 ) => Role::TextInput,
83 }
84 })
85}
86
87fn exposes_accessibility_value(masked: bool, content_type: Option<InputContentType>) -> bool {
88 !masked
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 id: Option<ElementId>,
111 state: TextInputState,
112 style: StyleRefinement,
113 size: Size,
114 prefix: Option<AnyElement>,
115 suffix: Option<AnyElement>,
116 height: Option<DefiniteLength>,
117 appearance: bool,
118 cleanable: bool,
119 mask_toggle: bool,
120 disabled: bool,
121 readonly: bool,
122 bordered: bool,
123 focus_bordered: bool,
124 tab_index: isize,
125 selected: bool,
126 content_type: Option<InputContentType>,
127 role: RoleOverride,
128 accessibility_id: Option<SharedString>,
129 aria_label: Option<SharedString>,
130
131 context_menu_builder: Option<Rc<dyn Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu>>,
135}
136
137impl Sizable for Input {
138 fn with_size(mut self, size: impl Into<Size>) -> Self {
139 self.size = size.into();
140 self
141 }
142}
143
144impl Selectable for Input {
145 fn selected(mut self, selected: bool) -> Self {
146 self.selected = selected;
147 self
148 }
149
150 fn is_selected(&self) -> bool {
151 self.selected
152 }
153}
154
155impl crate::FocusableExt for Input {
156 fn focus_ring(mut self, enabled: bool) -> Self {
157 self.focus_bordered = enabled;
158 self
159 }
160
161 fn is_focus_ring_enabled(&self) -> bool {
162 self.focus_bordered
163 }
164}
165
166impl Input {
167 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
169 self.id = Some(id.into());
170 self
171 }
172
173 pub fn new(state: &Entity<InputState>) -> Self {
175 Self::with_state(state.clone().into())
176 }
177
178 pub(crate) fn from_state(state: impl Into<TextInputState>) -> Self {
183 Self::with_state(state.into())
184 }
185
186 fn with_state(state: TextInputState) -> Self {
187 Self {
188 id: None,
189 state,
190 size: Size::default(),
191 style: StyleRefinement::default(),
192 prefix: None,
193 suffix: None,
194 height: None,
195 appearance: true,
196 cleanable: false,
197 mask_toggle: false,
198 disabled: false,
199 readonly: false,
200 bordered: true,
201 focus_bordered: true,
202 tab_index: 0,
203 selected: false,
204 content_type: None,
205 role: RoleOverride::default(),
206 accessibility_id: None,
207 aria_label: None,
208 context_menu_builder: None,
209 }
210 }
211
212 pub fn accessibility_id(mut self, id: impl Into<SharedString>) -> Self {
214 self.accessibility_id = Some(id.into());
215 self
216 }
217
218 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
219 self.aria_label = Some(label.into());
220 self
221 }
222
223 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
224 self.prefix = Some(prefix.into_any_element());
225 self
226 }
227
228 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
229 self.suffix = Some(suffix.into_any_element());
230 self
231 }
232
233 pub fn h_full(mut self) -> Self {
235 self.height = Some(relative(1.));
236 self
237 }
238
239 pub fn h(mut self, height: impl Into<DefiniteLength>) -> Self {
241 self.height = Some(height.into());
242 self
243 }
244
245 pub fn appearance(mut self, appearance: bool) -> Self {
247 self.appearance = appearance;
248 self
249 }
250
251 pub fn bordered(mut self, bordered: bool) -> Self {
253 self.bordered = bordered;
254 self
255 }
256
257 pub fn focus_bordered(mut self, bordered: bool) -> Self {
259 self.focus_bordered = bordered;
260 self
261 }
262
263 pub fn cleanable(mut self, cleanable: bool) -> Self {
265 self.cleanable = cleanable;
266 self
267 }
268
269 pub fn mask_toggle(mut self) -> Self {
271 self.mask_toggle = true;
272 self
273 }
274
275 pub fn content_type(mut self, content_type: InputContentType) -> Self {
280 self.content_type = Some(content_type);
281 self
282 }
283
284 pub fn role(mut self, role: impl Into<RoleOverride>) -> Self {
288 self.role = role.into();
289 self
290 }
291
292 pub fn disabled(mut self, disabled: bool) -> Self {
294 self.disabled = disabled;
295 self
296 }
297
298 pub fn readonly(mut self, readonly: bool) -> Self {
304 self.readonly = readonly;
305 self
306 }
307
308 pub fn tab_index(mut self, index: isize) -> Self {
310 self.tab_index = index;
311 self
312 }
313
314 pub fn context_menu(
318 mut self,
319 f: impl Fn(NativeMenu, &mut Window, &mut App) -> NativeMenu + 'static,
320 ) -> Self {
321 self.context_menu_builder = Some(Rc::new(f));
322 self
323 }
324
325 fn render_toggle_mask_button(state: &TextInputState, cx: &App) -> impl IntoElement {
326 let masked = state.presentation(cx).is_masked();
327 Button::new("toggle-mask")
328 .icon(if masked {
329 IconName::Eye
330 } else {
331 IconName::EyeOff
332 })
333 .xsmall()
334 .text()
335 .tab_stop(false)
336 .on_click({
337 let state = state.clone();
338 move |_, window, cx| state.toggle_masked(window, cx)
339 })
340 }
341
342 fn handle_accessibility_set_value(
343 state: &TextInputState,
344 data: Option<&gpui::accesskit::ActionData>,
345 window: &mut Window,
346 cx: &mut App,
347 ) {
348 let Some(gpui::accesskit::ActionData::Value(value)) = data else {
349 return;
350 };
351 state.replace_all(value.to_string(), window, cx);
352 }
353
354 fn render_editor(
356 input_state: TextInputState,
357 search_panel: Option<AnyElement>,
358 _: &Window,
359 ) -> impl IntoElement {
360 v_flex().size_full().children(search_panel).child(
361 div()
362 .relative()
363 .flex_1()
364 .child(input_state.into_any_element()),
365 )
366 }
367}
368
369impl Styled for Input {
370 fn style(&mut self) -> &mut StyleRefinement {
371 &mut self.style
372 }
373}
374
375impl RenderOnce for Input {
376 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
377 const LINE_HEIGHT: Rems = Rems(1.25);
378 let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
379 let state = self.state.clone();
380 sync_focused_input_registry(&state, window, cx);
382
383 state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx);
384 state.set_editor_style(
385 gpui_base::input::InputEditorStyle {
386 foreground: cx.theme().foreground,
387 muted_foreground: cx.theme().muted_foreground,
388 background: cx.theme().editor_background(),
389 border: cx.theme().border,
390 selection: cx.theme().selection,
391 caret: cx.theme().caret,
392 diagnostics: gpui_base::input::DiagnosticColors {
393 error: cx.theme().highlight_theme.style.status.error(cx),
394 warning: cx.theme().highlight_theme.style.status.warning(cx),
395 info: cx.theme().highlight_theme.style.status.info(cx),
396 hint: cx.theme().highlight_theme.style.status.hint(cx),
397 },
398 highlight_styles: cx.theme().highlight_theme.clone(),
399 editor_invisible: cx.theme().highlight_theme.style.editor_invisible,
400 editor_active_line: cx.theme().highlight_theme.style.editor_active_line,
401 editor_gutter_background: cx.theme().highlight_theme.style.editor_gutter_background,
402 fold_icon_renderer: Some(Rc::new(|ix, is_folded| {
403 Button::new(("fold-icon", ix))
404 .ghost()
405 .icon(if is_folded {
406 IconName::ChevronRight
407 } else {
408 IconName::ChevronDown
409 })
410 .xsmall()
411 .rounded(ButtonRounded::Small)
412 .size(px(14.))
413 .selected(is_folded)
414 .into_any_element()
415 })),
416 },
417 cx,
418 );
419 state.set_editor_paddings(
420 if state.presentation(cx).is_multi_line() {
421 Edges {
422 top: self.size.input_py(),
423 right: self.size.input_px(),
424 bottom: self.size.input_py(),
425 left: self.size.input_px(),
426 }
427 } else {
428 Edges::default()
429 },
430 cx,
431 );
432 state.set_disabled(self.disabled, cx);
433 state.set_readonly(self.readonly, cx);
434 state.set_text_align(text_align, cx);
435 let custom = self.context_menu_builder.clone();
436 state.on_context_menu(
437 Rc::new(move |_, capabilities, position, window, cx| {
438 let menu = if let Some(custom) = custom.as_ref() {
439 custom(NativeMenu::new(), window, cx)
440 } else {
441 let enabled = !capabilities.is_disabled();
442 let editable = enabled && !capabilities.is_readonly();
445 let mut menu = NativeMenu::new();
446 if capabilities.is_code_editor() {
447 menu = menu
448 .menu_with_disabled(
449 t!("Input.Go to Definition"),
450 !(enabled && capabilities.has_definition()),
451 Box::new(gpui_base::input::GoToDefinition),
452 )
453 .menu_with_disabled(
454 t!("Input.Show Code Actions"),
455 !(editable && capabilities.has_code_actions()),
456 Box::new(gpui_base::input::ToggleCodeActions),
457 )
458 .separator();
459 }
460 menu.menu_with_disabled(
461 t!("Input.Cut"),
462 !(editable && capabilities.is_copyable()),
463 Box::new(gpui_base::input::Cut),
464 )
465 .menu_with_disabled(
466 t!("Input.Copy"),
467 !capabilities.is_copyable(),
468 Box::new(gpui_base::input::Copy),
469 )
470 .menu_with_disabled(
471 t!("Input.Paste"),
472 !(editable && cx.read_from_clipboard().is_some()),
473 Box::new(gpui_base::input::Paste),
474 )
475 .separator()
476 .menu(
477 t!("Input.Select All"),
478 Box::new(gpui_base::input::SelectAll),
479 )
480 };
481 menu.show(position, window, cx);
482 }),
483 cx,
484 );
485 let overlays = state.render_overlays(window, cx);
486
487 let presentation = state.presentation(cx);
488 let content_type = self.content_type;
489 let disabled = self.disabled;
490 let is_multi_line = presentation.is_multi_line();
491 let accessibility_role = accessibility_role(is_multi_line, content_type, self.role);
492 let accessibility_state = state.clone();
493 let accessibility_value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
496 && exposes_accessibility_value(presentation.is_masked(), content_type))
497 .then(|| state.text(cx).to_string());
498 let input_focused =
499 presentation.focus_handle().is_focused(window) && !presentation.is_disabled();
500 if input_focused {
501 sync_native_content_type(window, content_type, presentation.is_editable());
502 }
503 let frame_focus_handle = window
504 .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| {
505 cx.focus_handle()
506 })
507 .read(cx)
508 .clone();
509 let focused = input_focused
510 || (frame_focus_handle.contains_focused(window, cx) && !presentation.is_disabled());
511
512 let gap_x = match self.size {
513 Size::Small => px(4.),
514 Size::Large => px(8.),
515 _ => px(6.),
516 };
517
518 let (bg, _) = input_style(presentation.is_disabled(), cx);
519 let bg = if presentation.is_code_editor() {
520 cx.theme().editor_background()
521 } else {
522 bg
523 };
524 let bg = if presentation.is_disabled() {
525 bg.opacity(0.5)
526 } else {
527 bg
528 };
529 let prefix = self.prefix;
530 let suffix = self.suffix;
531 let show_clear_button = self.cleanable
532 && presentation.is_editable()
533 && !presentation.is_loading()
534 && state.text(cx).len() > 0
535 && !presentation.is_multi_line();
536 let has_suffix =
537 suffix.is_some() || presentation.is_loading() || self.mask_toggle || show_clear_button;
538
539 let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
540
541 let placeholder_is_mask = presentation.mask_placeholder() == placeholder.as_deref();
543
544 let aria_label = match self.aria_label {
545 Some(label) => Some(label),
546 None if placeholder_is_mask => None,
547 None => placeholder.clone(),
548 };
549 let id = self
550 .id
551 .unwrap_or_else(|| ("input", state.entity_id()).into());
552 BaseInput::new(id)
553 .focused(focused)
554 .disabled(disabled)
555 .track_focus(&frame_focus_handle)
556 .styles(|styles| {
557 styles.focused(|style| {
558 style.when(
559 self.appearance && self.bordered && self.focus_bordered,
560 |style| style.border_1().border_color(cx.theme().ring),
561 )
562 })
563 })
564 .role(accessibility_role)
565 .when_some(self.accessibility_id, |this, id| this.accessibility_id(id))
566 .when_some(aria_label, |this, label| this.aria_label(label))
567 .when_some(placeholder, |this, placeholder| {
568 this.aria_placeholder(placeholder)
569 })
570 .when_some(accessibility_value, |this, value| this.aria_value(value))
571 .when(!disabled, |this| {
572 this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
573 Self::handle_accessibility_set_value(&accessibility_state, data, window, cx);
574 })
575 })
576 .flex()
577 .size_full()
578 .line_height(LINE_HEIGHT)
579 .when(!is_multi_line, |this| {
580 this.input_px(self.size).input_py(self.size)
581 })
582 .input_h(self.size)
583 .input_text_size(self.size)
584 .items_center()
585 .when(presentation.is_multi_line(), |this| {
586 this.h_auto()
587 .when_some(self.height, |this, height| this.h(height))
588 })
589 .when(self.appearance, |this| {
590 this.bg(bg)
591 .rounded(cx.theme().radius)
592 .when(self.bordered, |this| {
593 this.border_1().border_color(cx.theme().input)
594 })
595 })
596 .items_center()
597 .gap(gap_x)
598 .refine_style(&self.style)
599 .when(
600 focused && self.appearance && self.bordered && self.focus_bordered,
601 |this| this.focus_ring_style(window, cx),
602 )
603 .children(prefix.map(|p| {
604 div()
605 .when(presentation.is_disabled(), |this| this.opacity(0.5))
606 .child(p)
607 }))
608 .when(presentation.is_multi_line(), |this| {
609 this.child(Self::render_editor(state.clone(), overlays.search, window))
610 })
611 .when(!presentation.is_multi_line(), |this| {
612 this.child(state.clone().into_any_element())
613 })
614 .when(has_suffix, |this| {
615 this.pr(self.size.input_px()).child(
616 h_flex()
617 .id("suffix")
618 .gap(gap_x)
619 .items_center()
620 .cursor_default()
621 .when(presentation.is_disabled(), |this| this.opacity(0.5))
622 .when(presentation.is_loading(), |this| {
623 this.child(Spinner::new().color(cx.theme().muted_foreground))
624 })
625 .when(self.mask_toggle, |this| {
626 this.child(Self::render_toggle_mask_button(&state, cx))
627 })
628 .when(show_clear_button, |this| {
629 this.child(clear_button(cx).on_click({
630 let state = state.clone();
631 move |_, window, cx| {
632 state.clean(window, cx);
633 state.focus(window, cx);
634 }
635 }))
636 })
637 .children(suffix),
638 )
639 })
640 .relative()
641 .children(overlays.floating)
642 .render(window, cx)
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::input::AnyInputState;
650
651 #[test]
652 fn content_types_map_to_accessibility_roles() {
653 let cases = [
654 (None, Role::TextInput),
655 (Some(InputContentType::Name), Role::TextInput),
656 (Some(InputContentType::NamePrefix), Role::TextInput),
657 (Some(InputContentType::GivenName), Role::TextInput),
658 (Some(InputContentType::MiddleName), Role::TextInput),
659 (Some(InputContentType::FamilyName), Role::TextInput),
660 (Some(InputContentType::NameSuffix), Role::TextInput),
661 (Some(InputContentType::Nickname), Role::TextInput),
662 (Some(InputContentType::JobTitle), Role::TextInput),
663 (Some(InputContentType::OrganizationName), Role::TextInput),
664 (Some(InputContentType::Location), Role::TextInput),
665 (Some(InputContentType::FullStreetAddress), Role::TextInput),
666 (Some(InputContentType::StreetAddressLine1), Role::TextInput),
667 (Some(InputContentType::StreetAddressLine2), Role::TextInput),
668 (Some(InputContentType::AddressCity), Role::TextInput),
669 (Some(InputContentType::AddressState), Role::TextInput),
670 (Some(InputContentType::AddressCityAndState), Role::TextInput),
671 (Some(InputContentType::Sublocality), Role::TextInput),
672 (Some(InputContentType::CountryName), Role::TextInput),
673 (Some(InputContentType::PostalCode), Role::TextInput),
674 (
675 Some(InputContentType::TelephoneNumber),
676 Role::PhoneNumberInput,
677 ),
678 (Some(InputContentType::EmailAddress), Role::EmailInput),
679 (Some(InputContentType::Url), Role::UrlInput),
680 (Some(InputContentType::CreditCardNumber), Role::TextInput),
681 (Some(InputContentType::CreditCardName), Role::TextInput),
682 (Some(InputContentType::CreditCardGivenName), Role::TextInput),
683 (
684 Some(InputContentType::CreditCardMiddleName),
685 Role::TextInput,
686 ),
687 (
688 Some(InputContentType::CreditCardFamilyName),
689 Role::TextInput,
690 ),
691 (
692 Some(InputContentType::CreditCardSecurityCode),
693 Role::TextInput,
694 ),
695 (
696 Some(InputContentType::CreditCardExpiration),
697 Role::TextInput,
698 ),
699 (
700 Some(InputContentType::CreditCardExpirationMonth),
701 Role::TextInput,
702 ),
703 (
704 Some(InputContentType::CreditCardExpirationYear),
705 Role::TextInput,
706 ),
707 (Some(InputContentType::CreditCardType), Role::TextInput),
708 (Some(InputContentType::Username), Role::TextInput),
709 (Some(InputContentType::Password), Role::PasswordInput),
710 (Some(InputContentType::NewPassword), Role::PasswordInput),
711 (Some(InputContentType::OneTimeCode), Role::TextInput),
712 (
713 Some(InputContentType::ShipmentTrackingNumber),
714 Role::TextInput,
715 ),
716 (Some(InputContentType::FlightNumber), Role::TextInput),
717 (Some(InputContentType::DateTime), Role::DateTimeInput),
718 (Some(InputContentType::Birthdate), Role::DateInput),
719 (Some(InputContentType::BirthdateDay), Role::TextInput),
720 (Some(InputContentType::BirthdateMonth), Role::TextInput),
721 (Some(InputContentType::BirthdateYear), Role::TextInput),
722 (Some(InputContentType::CellularEid), Role::TextInput),
723 (Some(InputContentType::CellularImei), Role::TextInput),
724 ];
725
726 for (content_type, role) in cases {
727 assert_eq!(
728 accessibility_role(false, content_type, RoleOverride::Implicit),
729 Some(role)
730 );
731 }
732 }
733
734 #[test]
735 fn multiline_inputs_keep_multiline_accessibility_role() {
736 assert_eq!(
737 accessibility_role(
738 true,
739 Some(InputContentType::Password),
740 RoleOverride::Implicit
741 ),
742 Some(Role::MultilineTextInput)
743 );
744 }
745
746 #[test]
747 fn explicit_accessibility_role_overrides_defaults() {
748 assert_eq!(
749 accessibility_role(
750 false,
751 Some(InputContentType::Password),
752 Role::TextInput.into()
753 ),
754 Some(Role::TextInput)
755 );
756 assert_eq!(
757 accessibility_role(
758 true,
759 Some(InputContentType::Password),
760 Role::TextInput.into()
761 ),
762 Some(Role::TextInput)
763 );
764 }
765
766 #[test]
767 fn presentational_role_emits_no_accessibility_node() {
768 assert_eq!(
769 accessibility_role(
770 false,
771 Some(InputContentType::Password),
772 RoleOverride::Presentational
773 ),
774 None
775 );
776 assert_eq!(
777 accessibility_role(true, None, RoleOverride::Presentational),
778 None
779 );
780 }
781
782 #[test]
783 fn role_option_converts_to_the_matching_override() {
784 assert_eq!(
785 RoleOverride::from(Some(Role::Button)),
786 RoleOverride::Role(Role::Button)
787 );
788 assert_eq!(RoleOverride::from(None), RoleOverride::Presentational);
789 }
790
791 #[gpui::test]
792 fn editable_input_offers_accessibility_write_action(cx: &mut gpui::TestAppContext) {
793 use crate::ElementExt as _;
794 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
795 use std::sync::{Arc, Mutex};
796
797 type EmittedState = Option<(Option<String>, bool)>;
798
799 struct InputA11yProbe {
800 state: Entity<InputState>,
801 emitted: Arc<Mutex<EmittedState>>,
802 }
803
804 impl Render for InputA11yProbe {
805 fn render(
806 &mut self,
807 _window: &mut Window,
808 _cx: &mut gpui::Context<Self>,
809 ) -> impl IntoElement {
810 let state = self.state.clone();
811 let emitted = self.emitted.clone();
812 div().on_prepaint(move |_, window, cx| {
813 let input = Input::new(&state).render(window, cx).into_element();
814 let mut node = gpui::accesskit::Node::new(Role::TextInput);
815 input.write_a11y_info(&mut node);
816 *emitted.lock().unwrap() = Some((
817 node.value().map(ToOwned::to_owned),
818 node.supports_action(AccessibleAction::SetValue),
819 ));
820 })
821 }
822 }
823
824 cx.update(crate::init);
825 let emitted = Arc::new(Mutex::new(None));
826 let captured = emitted.clone();
827 let (probe, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
828 state: cx.new(|cx| InputState::new(window, cx).default_value("initial")),
829 emitted,
830 });
831 cx.update(|window, cx| {
832 let _ = window.draw(cx);
833 });
834 let expected_value = cfg!(feature = "test-support").then(|| "initial".to_owned());
837 assert_eq!(*captured.lock().unwrap(), Some((expected_value, true)));
838
839 let state = probe.read_with(cx, |probe, _| probe.state.clone());
840 let base: TextInputState = state.clone().into();
841 cx.update(|window, cx| {
842 Input::handle_accessibility_set_value(&base, None, window, cx);
843 });
844 assert_eq!(state.read_with(cx, |state, _| state.value()), "initial");
845
846 let action = gpui::accesskit::ActionData::Value("updated".into());
847 cx.update(|window, cx| {
848 Input::handle_accessibility_set_value(&base, Some(&action), window, cx);
849 });
850 assert_eq!(state.read_with(cx, |state, _| state.value()), "updated");
851 }
852
853 #[gpui::test]
854 fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) {
855 use crate::ElementExt as _;
856 use gpui::{AppContext as _, Element as _, IntoElement as _, Render};
857 use std::sync::{Arc, Mutex};
858
859 type EmittedIds = Vec<Option<String>>;
860
861 struct InputA11yProbe {
862 state: Entity<InputState>,
863 emitted: Arc<Mutex<EmittedIds>>,
864 }
865
866 impl Render for InputA11yProbe {
867 fn render(
868 &mut self,
869 _window: &mut Window,
870 _cx: &mut gpui::Context<Self>,
871 ) -> impl IntoElement {
872 let state = self.state.clone();
873 let emitted = self.emitted.clone();
874 div().on_prepaint(move |_, window, cx| {
875 let mut author_id_of = |input: Input| {
876 let mut node = gpui::accesskit::Node::new(Role::TextInput);
877 input
878 .render(window, cx)
879 .into_element()
880 .write_a11y_info(&mut node);
881 node.author_id().map(ToOwned::to_owned)
882 };
883
884 *emitted.lock().unwrap() = vec![
885 author_id_of(Input::new(&state)),
886 author_id_of(Input::new(&state).accessibility_id("search.query")),
887 ];
888 })
889 }
890 }
891
892 cx.update(crate::init);
893 let emitted = Arc::new(Mutex::new(Vec::new()));
894 let captured = emitted.clone();
895 let (_, cx) = cx.add_window_view(move |window, cx| InputA11yProbe {
896 state: cx.new(|cx| InputState::new(window, cx)),
897 emitted,
898 });
899 cx.update(|window, cx| {
900 let _ = window.draw(cx);
901 });
902
903 assert_eq!(
904 *captured.lock().unwrap(),
905 vec![None, Some("search.query".into())]
906 );
907 }
908
909 #[test]
910 fn accessibility_value_is_hidden_for_secret_inputs() {
911 assert!(exposes_accessibility_value(false, None));
912 assert!(!exposes_accessibility_value(true, None));
913 assert!(!exposes_accessibility_value(
914 false,
915 Some(InputContentType::Password)
916 ));
917 assert!(!exposes_accessibility_value(
918 false,
919 Some(InputContentType::NewPassword)
920 ));
921 }
922
923 #[gpui::test]
924 fn focused_input_registry_tracks_focus_and_blur(cx: &mut gpui::TestAppContext) {
925 use crate::{Root, WindowExt as _};
926 use gpui::{AppContext as _, Render};
927
928 struct Probe {
929 input: Entity<InputState>,
930 textarea: Entity<crate::input::TextareaState>,
931 editor: Entity<crate::input::EditorState>,
932 otp: Entity<gpui_base::OtpState>,
933 other: gpui::FocusHandle,
934 }
935 impl Render for Probe {
936 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
937 div()
938 .child(div().track_focus(&self.other))
939 .child(Input::new(&self.input))
940 .child(crate::input::Textarea::new(&self.textarea))
941 .child(crate::input::Editor::new(&self.editor))
942 .child(crate::input::OtpInput::new(&self.otp))
943 }
944 }
945
946 cx.update(crate::init);
947 let mut input = None;
948 let mut textarea = None;
949 let mut editor = None;
950 let mut other_focus = None;
951 let mut otp = None;
952 let window = cx.update(|cx| {
953 cx.open_window(Default::default(), |window, cx| {
954 let state = cx.new(|cx| InputState::new(window, cx));
955 let textarea_state = cx.new(|cx| crate::input::TextareaState::new(window, cx));
956 let editor_state =
957 cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"));
958 let otp_state = cx.new(|cx| gpui_base::OtpState::new(6, window, cx));
959 input = Some(state.clone());
960 textarea = Some(textarea_state.clone());
961 editor = Some(editor_state.clone());
962 otp = Some(otp_state.clone());
963 let other = cx.focus_handle();
964 other_focus = Some(other.clone());
965 let probe = cx.new(|_| Probe {
966 input: state,
967 textarea: textarea_state,
968 editor: editor_state,
969 otp: otp_state,
970 other,
971 });
972 cx.new(|cx| Root::new(probe, window, cx))
973 })
974 .unwrap()
975 });
976 let input = input.unwrap();
977 let textarea = textarea.unwrap();
978 let editor = editor.unwrap();
979 let otp = otp.unwrap();
980 let other_focus = other_focus.unwrap();
981 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
982
983 let cases: Vec<AnyInputState> = vec![
985 input.clone().into(),
986 textarea.clone().into(),
987 editor.clone().into(),
988 otp.clone().into(),
989 ];
990 for expected in cases {
991 cx.update(|window, cx| {
992 let _ = window.draw(cx);
993 });
994 cx.update(|window, cx| expected.focus_handle(cx).focus(window, cx));
995 cx.run_until_parked();
996 cx.update(|window, cx| {
997 let _ = window.draw(cx);
998 });
999 assert_eq!(
1000 cx.update(|window, cx| window.focused_input(cx)),
1001 Some(expected)
1002 );
1003
1004 cx.update(|window, cx| other_focus.clone().focus(window, cx));
1005 cx.run_until_parked();
1006 cx.update(|window, cx| {
1007 let _ = window.draw(cx);
1008 });
1009 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1010 }
1011 }
1012
1013 #[gpui::test]
1014 fn focused_input_registry_ignores_input_removed_while_focused(cx: &mut gpui::TestAppContext) {
1015 use crate::{Root, WindowExt as _};
1016 use gpui::{AppContext as _, Render};
1017
1018 struct Probe {
1019 input: Entity<InputState>,
1020 show_input: bool,
1021 other: gpui::FocusHandle,
1022 }
1023 impl Render for Probe {
1024 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1025 let base = div().child(div().track_focus(&self.other));
1026 if self.show_input {
1027 base.child(Input::new(&self.input))
1028 } else {
1029 base
1030 }
1031 }
1032 }
1033
1034 cx.update(crate::init);
1035 let mut input = None;
1036 let mut other_focus = None;
1037 let mut probe_entity = None;
1038 let window = cx.update(|cx| {
1039 cx.open_window(Default::default(), |window, cx| {
1040 let state = cx.new(|cx| InputState::new(window, cx));
1041 input = Some(state.clone());
1042 let other = cx.focus_handle();
1043 other_focus = Some(other.clone());
1044 let probe = cx.new(|_| Probe {
1045 input: state,
1046 show_input: true,
1047 other,
1048 });
1049 probe_entity = Some(probe.clone());
1050 cx.new(|cx| Root::new(probe, window, cx))
1051 })
1052 .unwrap()
1053 });
1054 let input: AnyInputState = input.unwrap().into();
1055 let other_focus = other_focus.unwrap();
1056 let probe = probe_entity.unwrap();
1057 let mut cx = gpui::VisualTestContext::from_window(window.into(), cx);
1058
1059 cx.update(|window, cx| {
1060 let _ = window.draw(cx);
1061 });
1062 cx.update(|window, cx| input.focus_handle(cx).focus(window, cx));
1063 cx.run_until_parked();
1064 cx.update(|window, cx| {
1065 let _ = window.draw(cx);
1066 });
1067 assert_eq!(
1068 cx.update(|window, cx| window.focused_input(cx)),
1069 Some(input.clone())
1070 );
1071
1072 cx.update(|window, cx| {
1076 probe.update(cx, |probe, cx| {
1077 probe.show_input = false;
1078 cx.notify();
1079 });
1080 other_focus.focus(window, cx);
1081 });
1082 cx.run_until_parked();
1083 cx.update(|window, cx| {
1084 let _ = window.draw(cx);
1085 });
1086 assert!(!cx.update(|window, cx| window.has_focused_input(cx)));
1087 assert_eq!(cx.update(|window, cx| window.focused_input(cx)), None);
1088 }
1089}