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