diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index ebe9539147..febe670588 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -1,10 +1,10 @@ use gpui::{ App, AppContext as _, ClickEvent, Context, Entity, InteractiveElement, IntoElement, - ParentElement as _, Render, Styled, Subscription, Window, div, + ParentElement as _, Render, Role, Styled, Subscription, Window, div, }; use crate::section; -use gpui_component::{button::*, input::*, *}; +use gpui_component::{button::*, input::*, label::Label, *}; const CODE_EXAMPLE: &str = r#"{"single_line":"code editor"}"#; @@ -32,10 +32,18 @@ pub struct InputStory { custom_menu_input: Entity, code_input: Entity, color_input: Entity, + content_type_inputs: Vec, _subscriptions: Vec, } +struct ContentTypeInput { + label: &'static str, + content_type: InputContentType, + input: Entity, + mask_toggle: bool, +} + impl super::Story for InputStory { fn title() -> &'static str { "Input" @@ -139,6 +147,117 @@ impl InputStory { .default_value("Right Aligned Text") }); + let content_type_inputs = vec![ + Self::new_content_type_input( + window, + cx, + "Name", + InputContentType::Name, + "Full name", + "Jane Doe", + false, + ), + Self::new_content_type_input( + window, + cx, + "Username", + InputContentType::Username, + "Username", + "jane.doe", + false, + ), + Self::new_content_type_input( + window, + cx, + "Password", + InputContentType::Password, + "Current password", + "current-password", + true, + ), + Self::new_content_type_input( + window, + cx, + "New password", + InputContentType::NewPassword, + "New password", + "new-password", + true, + ), + Self::new_content_type_input( + window, + cx, + "One-time code", + InputContentType::OneTimeCode, + "123456", + "123456", + false, + ), + Self::new_content_type_input( + window, + cx, + "Email", + InputContentType::EmailAddress, + "Email address", + "jane.doe@example.com", + false, + ), + Self::new_content_type_input( + window, + cx, + "Telephone", + InputContentType::TelephoneNumber, + "Telephone number", + "+1 415 555 0198", + false, + ), + Self::new_content_type_input( + window, + cx, + "URL", + InputContentType::Url, + "Website URL", + "https://example.com", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card number", + InputContentType::CreditCardNumber, + "Card number", + "4242 4242 4242 4242", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card expiration", + InputContentType::CreditCardExpiration, + "MM/YY", + "12/28", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card security code", + InputContentType::CreditCardSecurityCode, + "CVC", + "123", + true, + ), + Self::new_content_type_input( + window, + cx, + "Postal code", + InputContentType::PostalCode, + "Postal code", + "94107", + false, + ), + ]; + let _subscriptions = vec![ cx.subscribe_in(&input1, window, Self::on_input_event), cx.subscribe_in(&input2, window, Self::on_input_event), @@ -172,10 +291,60 @@ impl InputStory { color_input, input_text_centered, input_text_right, + content_type_inputs, _subscriptions, } } + fn new_content_type_input( + window: &mut Window, + cx: &mut Context, + label: &'static str, + content_type: InputContentType, + placeholder: &'static str, + default_value: &'static str, + masked: bool, + ) -> ContentTypeInput { + let input = cx.new(|cx| { + let state = InputState::new(window, cx) + .placeholder(placeholder) + .default_value(default_value); + + if masked { state.masked(true) } else { state } + }); + + ContentTypeInput { + label, + content_type, + input, + mask_toggle: masked, + } + } + + fn render_content_type_input(item: &ContentTypeInput) -> impl IntoElement { + let input = Input::new(&item.input) + .content_type(item.content_type) + .flex_1(); + let input = if item.mask_toggle { + input.mask_toggle() + } else { + input + }; + + h_flex() + .w_full() + .items_center() + .gap_3() + .child( + Label::new(item.label) + .w_48() + .flex_shrink_0() + .text_sm() + .whitespace_nowrap(), + ) + .child(input) + } + fn on_input_event( &mut self, state: &Entity, @@ -221,13 +390,25 @@ impl Render for InputStory { section("Normal Input") .max_w_md() .child(Input::new(&self.input1).cleanable(true)) - .child(Input::new(&self.input2)), + .child(Input::new(&self.input2).role(Role::EmailInput)), ) .child( section("Input State") .max_w_md() .child(Input::new(&self.disabled_input).disabled(true)) - .child(Input::new(&self.mask_input).mask_toggle().cleanable(true)), + .child( + Input::new(&self.mask_input) + .content_type(InputContentType::Password) + .mask_toggle() + .cleanable(true), + ), + ) + .child( + section("Content Type").max_w_lg().children( + self.content_type_inputs + .iter() + .map(Self::render_content_type_input), + ), ) .child( section("Text Align").max_w_lg().child( diff --git a/crates/ui/src/input/content_type.rs b/crates/ui/src/input/content_type.rs new file mode 100644 index 0000000000..3f970b7214 --- /dev/null +++ b/crates/ui/src/input/content_type.rs @@ -0,0 +1,244 @@ +use gpui::Window; + +/// Semantic content type for an [`Input`](super::Input). +/// +/// These variants mirror Swift's text content types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputContentType { + /// A person's full name. + Name, + /// A name prefix, such as Mr. or Dr. + NamePrefix, + /// A person's given name. + GivenName, + /// A person's middle name. + MiddleName, + /// A person's family name. + FamilyName, + /// A name suffix, such as Jr. or PhD. + NameSuffix, + /// A nickname. + Nickname, + /// A job title. + JobTitle, + /// An organization or company name. + OrganizationName, + /// A location name. + Location, + /// A full street address. + FullStreetAddress, + /// The first line of a street address. + StreetAddressLine1, + /// The second line of a street address. + StreetAddressLine2, + /// A city or locality. + AddressCity, + /// A state, province, or region. + AddressState, + /// A combined city and state. + AddressCityAndState, + /// A sublocality, district, or neighborhood. + Sublocality, + /// A country name. + CountryName, + /// A postal or ZIP code. + PostalCode, + /// A telephone number. + TelephoneNumber, + /// An email address. + EmailAddress, + /// A URL. + Url, + /// A credit card number. + CreditCardNumber, + /// The full name on a credit card. + CreditCardName, + /// The given name on a credit card. + CreditCardGivenName, + /// The middle name on a credit card. + CreditCardMiddleName, + /// The family name on a credit card. + CreditCardFamilyName, + /// The security code on a credit card. + CreditCardSecurityCode, + /// A credit card expiration date. + CreditCardExpiration, + /// A credit card expiration month. + CreditCardExpirationMonth, + /// A credit card expiration year. + CreditCardExpirationYear, + /// A credit card type. + CreditCardType, + /// A username or account identifier. + Username, + /// The password for the account identified by the username field. + Password, + /// A new password, such as during sign up or password reset. + NewPassword, + /// A one-time verification code. + OneTimeCode, + /// A parcel shipment tracking number. + ShipmentTrackingNumber, + /// An airline flight number. + FlightNumber, + /// A date, time, or duration. + DateTime, + /// A birthdate. + Birthdate, + /// A birthdate day. + BirthdateDay, + /// A birthdate month. + BirthdateMonth, + /// A birthdate year. + BirthdateYear, + /// An eSIM EID. + CellularEid, + /// A cellular IMEI. + CellularImei, +} + +impl InputContentType { + #[cfg(target_os = "macos")] + pub(crate) const fn ns_text_content_type(self) -> Option<&'static str> { + match self { + Self::Name => Some("name"), + Self::NamePrefix => Some("honorific-prefix"), + Self::GivenName => Some("given-name"), + Self::MiddleName => Some("additional-name"), + Self::FamilyName => Some("family-name"), + Self::NameSuffix => Some("honorific-suffix"), + Self::Nickname => Some("nickname"), + Self::JobTitle => Some("organization-title"), + Self::OrganizationName => Some("organization"), + Self::Location => Some("location"), + Self::FullStreetAddress => Some("street-address"), + Self::StreetAddressLine1 => Some("address-line1"), + Self::StreetAddressLine2 => Some("address-line2"), + Self::AddressCity => Some("address-level2"), + Self::AddressState => Some("address-level1"), + Self::AddressCityAndState => Some("address-level1+2"), + Self::Sublocality => Some("address-level3"), + Self::CountryName => Some("country-name"), + Self::PostalCode => Some("postal-code"), + Self::TelephoneNumber => Some("tel"), + Self::EmailAddress => Some("email"), + Self::Url => Some("url"), + Self::CreditCardNumber => Some("cc-number"), + Self::CreditCardName => Some("cc-name"), + Self::CreditCardGivenName => Some("cc-given-name"), + Self::CreditCardMiddleName => Some("cc-additional-name"), + Self::CreditCardFamilyName => Some("cc-family-name"), + Self::CreditCardSecurityCode => Some("cc-csc"), + Self::CreditCardExpiration => Some("cc-exp"), + Self::CreditCardExpirationMonth => Some("cc-exp-month"), + Self::CreditCardExpirationYear => Some("cc-exp-year"), + Self::CreditCardType => Some("cc-type"), + Self::Username => Some("username"), + Self::Password => Some("password"), + Self::NewPassword => Some("new-password"), + Self::OneTimeCode => Some("one-time-code"), + Self::ShipmentTrackingNumber => Some("shipment-tracking-number"), + Self::FlightNumber => Some("flight-number"), + Self::DateTime => Some("date-time"), + Self::Birthdate => Some("bday"), + Self::BirthdateDay => Some("bday-day"), + Self::BirthdateMonth => Some("bday-month"), + Self::BirthdateYear => Some("bday-year"), + Self::CellularEid | Self::CellularImei => None, + } + } +} + +pub(super) fn sync_native_content_type( + window: &mut Window, + content_type: Option, + disabled: bool, +) { + if disabled { + return; + } + + #[cfg(target_os = "macos")] + super::native::set_text_content_type(window, content_type); + + #[cfg(not(target_os = "macos"))] + let _ = (window, content_type); +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn content_type_maps_to_ns_text_content_type_values() { + let content_types = [ + (InputContentType::Name, Some("name")), + (InputContentType::NamePrefix, Some("honorific-prefix")), + (InputContentType::GivenName, Some("given-name")), + (InputContentType::MiddleName, Some("additional-name")), + (InputContentType::FamilyName, Some("family-name")), + (InputContentType::NameSuffix, Some("honorific-suffix")), + (InputContentType::Nickname, Some("nickname")), + (InputContentType::JobTitle, Some("organization-title")), + (InputContentType::OrganizationName, Some("organization")), + (InputContentType::Location, Some("location")), + (InputContentType::FullStreetAddress, Some("street-address")), + (InputContentType::StreetAddressLine1, Some("address-line1")), + (InputContentType::StreetAddressLine2, Some("address-line2")), + (InputContentType::AddressCity, Some("address-level2")), + (InputContentType::AddressState, Some("address-level1")), + ( + InputContentType::AddressCityAndState, + Some("address-level1+2"), + ), + (InputContentType::Sublocality, Some("address-level3")), + (InputContentType::CountryName, Some("country-name")), + (InputContentType::PostalCode, Some("postal-code")), + (InputContentType::TelephoneNumber, Some("tel")), + (InputContentType::EmailAddress, Some("email")), + (InputContentType::Url, Some("url")), + (InputContentType::CreditCardNumber, Some("cc-number")), + (InputContentType::CreditCardName, Some("cc-name")), + (InputContentType::CreditCardGivenName, Some("cc-given-name")), + ( + InputContentType::CreditCardMiddleName, + Some("cc-additional-name"), + ), + ( + InputContentType::CreditCardFamilyName, + Some("cc-family-name"), + ), + (InputContentType::CreditCardSecurityCode, Some("cc-csc")), + (InputContentType::CreditCardExpiration, Some("cc-exp")), + ( + InputContentType::CreditCardExpirationMonth, + Some("cc-exp-month"), + ), + ( + InputContentType::CreditCardExpirationYear, + Some("cc-exp-year"), + ), + (InputContentType::CreditCardType, Some("cc-type")), + (InputContentType::Username, Some("username")), + (InputContentType::Password, Some("password")), + (InputContentType::NewPassword, Some("new-password")), + (InputContentType::OneTimeCode, Some("one-time-code")), + ( + InputContentType::ShipmentTrackingNumber, + Some("shipment-tracking-number"), + ), + (InputContentType::FlightNumber, Some("flight-number")), + (InputContentType::DateTime, Some("date-time")), + (InputContentType::Birthdate, Some("bday")), + (InputContentType::BirthdateDay, Some("bday-day")), + (InputContentType::BirthdateMonth, Some("bday-month")), + (InputContentType::BirthdateYear, Some("bday-year")), + (InputContentType::CellularEid, None), + (InputContentType::CellularImei, None), + ]; + + for (content_type, native_value) in content_types { + assert_eq!(content_type.ns_text_content_type(), native_value); + } + } +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 97dc5a9023..467bea9bb6 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, Role, + IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Rems, RenderOnce, Role, StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, Window, div, px, relative, }; @@ -16,7 +16,9 @@ use crate::{IconName, Size}; use crate::{Selectable, StyledExt, h_flex}; use crate::{Sizable, StyleSized}; -use super::{InputState, element::EditorScrollbar}; +use super::{ + InputContentType, InputState, content_type::sync_native_content_type, element::EditorScrollbar, +}; /// Returns `(background, foreground)` colors for input-like components. pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { @@ -47,6 +49,8 @@ pub struct Input { focus_bordered: bool, tab_index: isize, selected: bool, + content_type: Option, + role: Option, /// An optional context menu builder to allow a custom context menu on the input. /// @@ -90,6 +94,8 @@ impl Input { focus_bordered: true, tab_index: 0, selected: false, + content_type: None, + role: None, context_menu_builder: None, } } @@ -146,6 +152,23 @@ impl Input { self } + /// Set the semantic content type for password managers and autofill. + /// + /// This is a component-level semantic hint. It does not change the text + /// value or masked rendering state. + pub fn content_type(mut self, content_type: InputContentType) -> Self { + self.content_type = Some(content_type); + self + } + + /// Override the accessible role for the input. + /// + /// If unset, the role is inferred from multi-line mode and content type. + pub fn role(mut self, role: Role) -> Self { + self.role = Some(role); + self + } + /// Set to disable the input field. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -190,6 +213,81 @@ impl Input { }) } + fn mouse_down_handler( + state: Entity, + content_type: Option, + disabled: bool, + ) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static { + move |event, window, cx| { + sync_native_content_type(window, content_type, disabled); + state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); + } + } + + fn accessibility_role( + is_multi_line: bool, + content_type: Option, + role: Option, + ) -> Role { + if let Some(role) = role { + return role; + } + + if is_multi_line { + return Role::MultilineTextInput; + } + + match content_type { + None => Role::TextInput, + Some(InputContentType::TelephoneNumber) => Role::PhoneNumberInput, + Some(InputContentType::EmailAddress) => Role::EmailInput, + Some(InputContentType::Url) => Role::UrlInput, + Some(InputContentType::Password | InputContentType::NewPassword) => Role::PasswordInput, + Some(InputContentType::DateTime) => Role::DateTimeInput, + Some(InputContentType::Birthdate) => Role::DateInput, + Some( + InputContentType::Name + | InputContentType::NamePrefix + | InputContentType::GivenName + | InputContentType::MiddleName + | InputContentType::FamilyName + | InputContentType::NameSuffix + | InputContentType::Nickname + | InputContentType::JobTitle + | InputContentType::OrganizationName + | InputContentType::Location + | InputContentType::FullStreetAddress + | InputContentType::StreetAddressLine1 + | InputContentType::StreetAddressLine2 + | InputContentType::AddressCity + | InputContentType::AddressState + | InputContentType::AddressCityAndState + | InputContentType::Sublocality + | InputContentType::CountryName + | InputContentType::PostalCode + | InputContentType::CreditCardNumber + | InputContentType::CreditCardName + | InputContentType::CreditCardGivenName + | InputContentType::CreditCardMiddleName + | InputContentType::CreditCardFamilyName + | InputContentType::CreditCardSecurityCode + | InputContentType::CreditCardExpiration + | InputContentType::CreditCardExpirationMonth + | InputContentType::CreditCardExpirationYear + | InputContentType::CreditCardType + | InputContentType::Username + | InputContentType::OneTimeCode + | InputContentType::ShipmentTrackingNumber + | InputContentType::FlightNumber + | InputContentType::BirthdateDay + | InputContentType::BirthdateMonth + | InputContentType::BirthdateYear + | InputContentType::CellularEid + | InputContentType::CellularImei, + ) => Role::TextInput, + } + } + /// This method must after the refine_style. fn render_editor( paddings: EdgesRefinement, @@ -258,7 +356,15 @@ impl RenderOnce for Input { }); let state = self.state.read(cx); + let content_type = self.content_type; + let disabled = self.disabled; + let is_multi_line = state.mode.is_multi_line(); + let accessibility_role = Self::accessibility_role(is_multi_line, content_type, self.role); let focused = state.focus_handle.is_focused(window) && !state.disabled; + if focused { + sync_native_content_type(window, content_type, state.disabled); + } + let gap_x = match self.size { Size::Small => px(4.), Size::Large => px(8.), @@ -289,11 +395,7 @@ impl RenderOnce for Input { div() .id(("input", self.state.entity_id())) - .role(if state.mode.is_multi_line() { - Role::MultilineTextInput - } else { - Role::TextInput - }) + .role(accessibility_role) .flex() .key_context(crate::input::CONTEXT) .track_focus(&state.focus_handle.clone()) @@ -361,11 +463,11 @@ impl RenderOnce for Input { .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) .on_mouse_down( MouseButton::Left, - window.listener_for(&self.state, InputState::on_mouse_down), + Self::mouse_down_handler(self.state.clone(), content_type, disabled), ) .on_mouse_down( MouseButton::Right, - window.listener_for(&self.state, InputState::on_mouse_down), + Self::mouse_down_handler(self.state.clone(), content_type, disabled), ) .on_mouse_up( MouseButton::Left, @@ -445,3 +547,116 @@ impl RenderOnce for Input { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_types_map_to_accessibility_roles() { + let cases = [ + (None, Role::TextInput), + (Some(InputContentType::Name), Role::TextInput), + (Some(InputContentType::NamePrefix), Role::TextInput), + (Some(InputContentType::GivenName), Role::TextInput), + (Some(InputContentType::MiddleName), Role::TextInput), + (Some(InputContentType::FamilyName), Role::TextInput), + (Some(InputContentType::NameSuffix), Role::TextInput), + (Some(InputContentType::Nickname), Role::TextInput), + (Some(InputContentType::JobTitle), Role::TextInput), + (Some(InputContentType::OrganizationName), Role::TextInput), + (Some(InputContentType::Location), Role::TextInput), + (Some(InputContentType::FullStreetAddress), Role::TextInput), + (Some(InputContentType::StreetAddressLine1), Role::TextInput), + (Some(InputContentType::StreetAddressLine2), Role::TextInput), + (Some(InputContentType::AddressCity), Role::TextInput), + (Some(InputContentType::AddressState), Role::TextInput), + (Some(InputContentType::AddressCityAndState), Role::TextInput), + (Some(InputContentType::Sublocality), Role::TextInput), + (Some(InputContentType::CountryName), Role::TextInput), + (Some(InputContentType::PostalCode), Role::TextInput), + ( + Some(InputContentType::TelephoneNumber), + Role::PhoneNumberInput, + ), + (Some(InputContentType::EmailAddress), Role::EmailInput), + (Some(InputContentType::Url), Role::UrlInput), + (Some(InputContentType::CreditCardNumber), Role::TextInput), + (Some(InputContentType::CreditCardName), Role::TextInput), + (Some(InputContentType::CreditCardGivenName), Role::TextInput), + ( + Some(InputContentType::CreditCardMiddleName), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardFamilyName), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardSecurityCode), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpiration), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpirationMonth), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpirationYear), + Role::TextInput, + ), + (Some(InputContentType::CreditCardType), Role::TextInput), + (Some(InputContentType::Username), Role::TextInput), + (Some(InputContentType::Password), Role::PasswordInput), + (Some(InputContentType::NewPassword), Role::PasswordInput), + (Some(InputContentType::OneTimeCode), Role::TextInput), + ( + Some(InputContentType::ShipmentTrackingNumber), + Role::TextInput, + ), + (Some(InputContentType::FlightNumber), Role::TextInput), + (Some(InputContentType::DateTime), Role::DateTimeInput), + (Some(InputContentType::Birthdate), Role::DateInput), + (Some(InputContentType::BirthdateDay), Role::TextInput), + (Some(InputContentType::BirthdateMonth), Role::TextInput), + (Some(InputContentType::BirthdateYear), Role::TextInput), + (Some(InputContentType::CellularEid), Role::TextInput), + (Some(InputContentType::CellularImei), Role::TextInput), + ]; + + for (content_type, role) in cases { + assert_eq!(Input::accessibility_role(false, content_type, None), role); + } + } + + #[test] + fn multiline_inputs_keep_multiline_accessibility_role() { + assert_eq!( + Input::accessibility_role(true, Some(InputContentType::Password), None), + Role::MultilineTextInput + ); + } + + #[test] + fn explicit_accessibility_role_overrides_defaults() { + assert_eq!( + Input::accessibility_role( + false, + Some(InputContentType::Password), + Some(Role::TextInput) + ), + Role::TextInput + ); + assert_eq!( + Input::accessibility_role( + true, + Some(InputContentType::Password), + Some(Role::TextInput) + ), + Role::TextInput + ); + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index f2875a010d..2ac5715ac5 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -4,6 +4,7 @@ pub(super) const MASK_CHAR: char = '•'; mod blink_cursor; mod change; mod clear_button; +mod content_type; mod cursor; mod display_map; mod element; @@ -13,6 +14,8 @@ mod lsp; mod mask_pattern; mod mode; mod movement; +#[cfg(target_os = "macos")] +mod native; mod number_input; mod otp_input; pub(crate) mod popovers; @@ -22,6 +25,7 @@ mod selection; mod state; pub(crate) use clear_button::*; +pub use content_type::*; pub use cursor::*; #[cfg(not(feature = "tree-sitter"))] pub use display_map::Tree; diff --git a/crates/ui/src/input/native.rs b/crates/ui/src/input/native.rs new file mode 100644 index 0000000000..6839ee4d6c --- /dev/null +++ b/crates/ui/src/input/native.rs @@ -0,0 +1,112 @@ +#[cfg(target_os = "macos")] +mod macos { + use std::{cell::RefCell, collections::HashMap, mem, ptr, sync::Once}; + + use gpui::Window; + use objc2::{ + ffi, msg_send, + rc::Retained, + runtime::{AnyObject, AnyProtocol, Imp, Sel}, + sel, + }; + use objc2_foundation::NSString; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + use crate::input::InputContentType; + + static INSTALL_TEXT_CONTENT: Once = Once::new(); + + thread_local! { + static CONTENT_TYPES: RefCell>> = + RefCell::new(HashMap::new()); + } + + pub(crate) fn set_text_content_type(window: &Window, content_type: Option) { + let Some(view) = ns_view(window) else { + return; + }; + + INSTALL_TEXT_CONTENT.call_once(|| install_text_content(view)); + if view + .class() + .instance_method(sel!(setContentType:)) + .is_none() + { + return; + } + + let ns_content_type = content_type + .and_then(InputContentType::ns_text_content_type) + .map(NSString::from_str); + let ns_content_type = ns_content_type.as_ref().map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }); + + unsafe { + let _: () = msg_send![view, setContentType: ns_content_type]; + } + } + + fn ns_view(window: &Window) -> Option<&AnyObject> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + + Some(unsafe { &*(handle.ns_view.as_ptr() as *const AnyObject) }) + } + + fn install_text_content(view: &AnyObject) { + let class = view.class(); + let class = class as *const _ as *mut _; + + unsafe { + let protocol = AnyProtocol::get(c"NSTextContent"); + if let Some(protocol) = protocol { + ffi::class_addProtocol(class, protocol); + } + + let content_type_imp: Imp = mem::transmute( + content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel) -> *mut AnyObject, + ); + let set_content_type_imp: Imp = mem::transmute( + set_content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut AnyObject), + ); + + ffi::class_addMethod(class, sel!(contentType), content_type_imp, c"@@:".as_ptr()); + ffi::class_addMethod( + class, + sel!(setContentType:), + set_content_type_imp, + c"v@:@".as_ptr(), + ); + } + } + + unsafe extern "C-unwind" fn content_type(this: &AnyObject, _: Sel) -> *mut AnyObject { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + content_types + .borrow() + .get(&key) + .map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }) + }) + } + + unsafe extern "C-unwind" fn set_content_type(this: &AnyObject, _: Sel, value: *mut AnyObject) { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + let mut content_types = content_types.borrow_mut(); + if value.is_null() { + content_types.remove(&key); + } else if let Some(value) = unsafe { Retained::retain(value.cast::()) } { + content_types.insert(key, value); + } + }); + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::set_text_content_type; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6c79bf3b13..21a5470a8b 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -11,7 +11,7 @@ mod icon; mod index_path; #[cfg(any(feature = "inspector", debug_assertions))] mod inspector; -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", not(test)))] mod macos_accessibility; mod root; mod styled; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 3e8ae32e50..7d531b8838 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -94,7 +94,7 @@ impl ActiveDialog { impl Root { /// Create a new Root view. pub fn new(view: impl Into, window: &mut Window, cx: &mut Context) -> Self { - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(test)))] crate::macos_accessibility::install_window_hit_test_forwarder(window); Self { diff --git a/docs/docs/components/input.md b/docs/docs/components/input.md index d332d5a837..02522d0247 100644 --- a/docs/docs/components/input.md +++ b/docs/docs/components/input.md @@ -86,6 +86,7 @@ let input = cx.new(|cx| ); Input::new(&input) + .content_type(InputContentType::Password) .mask_toggle() // Shows toggle button to reveal password ``` diff --git a/docs/zh-CN/docs/components/input.md b/docs/zh-CN/docs/components/input.md index 190979a66c..68ecf9875b 100644 --- a/docs/zh-CN/docs/components/input.md +++ b/docs/zh-CN/docs/components/input.md @@ -83,6 +83,7 @@ let input = cx.new(|cx| ); Input::new(&input) + .content_type(InputContentType::Password) .mask_toggle() ``` diff --git a/skills/gpui-component/references/usage.md b/skills/gpui-component/references/usage.md index b13c6f9d61..84b8a9cd8e 100644 --- a/skills/gpui-component/references/usage.md +++ b/skills/gpui-component/references/usage.md @@ -135,6 +135,7 @@ Input::new(&input).cleanable(true) // clear button Input::new(&input).disabled(true) Input::new(&input).prefix(Icon::new(IconName::Search).small()) Input::new(&input).suffix(Button::new("b").ghost().icon(IconName::X).xsmall()) +Input::new(&input).content_type(InputContentType::Password) Input::new(&input).mask_toggle() // password reveal toggle Input::new(&input).appearance(false) // remove default border/bg