1use crate::ActiveTheme;
3use gpui_kit::base::{
4 Input as BaseInput, InputBase, Textarea,
5 input::{InputState, TextareaState},
6};
7use gpui_kit::prelude::FluentBuilder;
8use gpui_kit::rems;
9use gpui_kit::{
10 AnyElement, App, ElementId, Entity, Focusable, InteractiveElement, Interactivity, IntoElement,
11 MouseButton, ParentElement, RenderOnce, StatefulInteractiveElement, StyleRefinement, Styled,
12 Window, div,
13};
14
15#[derive(IntoElement)]
18pub struct Input {
19 base: InputBase,
20 editor: BaseInput,
21 prefix: Option<AnyElement>,
22 suffix: Option<AnyElement>,
23 native_mask_text: bool,
24}
25
26impl Input {
27 pub fn prefix(mut self, content: impl IntoElement) -> Self {
28 self.prefix = Some(content.into_any_element());
29 self
30 }
31
32 pub fn suffix(mut self, content: impl IntoElement) -> Self {
33 self.suffix = Some(content.into_any_element());
34 self
35 }
36}
37
38impl Styled for Input {
39 fn style(&mut self) -> &mut StyleRefinement {
40 self.base.style()
41 }
42}
43
44impl InteractiveElement for Input {
45 fn interactivity(&mut self) -> &mut Interactivity {
46 self.base.interactivity()
47 }
48}
49
50impl StatefulInteractiveElement for Input {}
51
52impl RenderOnce for Input {
53 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
54 let affix = |content| {
55 div()
56 .flex()
57 .items_center()
58 .flex_shrink_0()
59 .text_color(cx.macos().secondary)
60 .child(content)
61 };
62 self.base
63 .children(self.prefix.map(affix))
64 .child(
65 div()
66 .flex_1()
67 .min_w_0()
68 .when(self.native_mask_text, |editor| {
73 editor.font_family("Helvetica Neue").text_size(rems(0.8))
74 })
75 .child(self.editor),
76 )
77 .children(self.suffix.map(affix))
78 }
79}
80
81fn frame(id: impl Into<ElementId>, focused: bool, cx: &App) -> InputBase {
82 let t = cx.macos();
83 InputBase::new(id)
84 .focused(focused)
85 .w_full()
86 .min_w_0()
87 .px(rems(0.375))
88 .border_1()
89 .rounded(rems(0.375))
90 .border_color(t.control_border())
91 .bg(t.surface)
92 .text_color(t.foreground)
93 .font_family(t.font.clone())
94 .text_size(rems(0.8125))
95 .line_height(gpui_kit::relative(1.2))
96 .styles(|s| s.focused(|s| s.border_color(t.accent).shadow(t.focus_shadow())))
97}
98
99pub fn input(
100 id: impl Into<ElementId>,
101 state: &Entity<InputState>,
102 window: &Window,
103 cx: &mut App,
104) -> Input {
105 let style = cx.macos().input_style();
106 state.update(cx, |state, cx| {
107 state.set_editor_style(style);
108 if state.presentation().is_masked() {
109 state.set_mask_character('●', cx);
110 }
111 });
112 let masked = state.read(cx).presentation().is_masked();
113 let native_mask_text = masked && !state.read(cx).value().is_empty();
114 let focused = window.is_window_active() && state.read(cx).focus_handle(cx).is_focused(window);
115 let target = state.clone();
116 let base = frame(id, focused, cx)
117 .when(masked, |frame| frame.role(gpui_kit::Role::PasswordInput))
118 .min_h(rems(1.5))
119 .py(rems(0.1875))
120 .flex()
121 .items_center()
122 .gap(rems(0.25))
123 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
124 target.update(cx, |state, cx| state.focus(window, cx));
125 });
126 Input {
127 base,
128 editor: BaseInput::new(state),
129 prefix: None,
130 suffix: None,
131 native_mask_text,
132 }
133}
134
135pub fn textarea(
136 id: impl Into<ElementId>,
137 state: &Entity<TextareaState>,
138 window: &Window,
139 cx: &mut App,
140) -> InputBase {
141 let style = cx.macos().input_style();
142 state.update(cx, |state, _| state.set_editor_style(style));
143 let focused = window.is_window_active() && state.read(cx).focus_handle(cx).is_focused(window);
144 let target = state.clone();
145 frame(id, focused, cx)
146 .min_h(rems(6.))
147 .line_height(gpui_kit::relative(1.4))
148 .py(rems(0.3125))
149 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
150 target.update(cx, |state, cx| state.focus(window, cx));
151 })
152 .child(Textarea::new(state))
153}
154
155pub fn number_input(state: &Entity<InputState>, cx: &mut App) -> gpui_kit::base::NumberInput {
157 use gpui_kit::ParentElement as _;
158 let t = cx.macos().clone();
159 state.update(cx, |state, cx| {
160 state.set_editor_style(t.input_style());
161 state.set_text_align(gpui_kit::TextAlign::Right, cx);
162 });
163 let minus = t.clone();
164 gpui_kit::base::NumberInput::new(state)
165 .w(rems(7.5))
166 .h(rems(1.5))
167 .controls_right()
168 .overflow_hidden()
169 .flex()
170 .items_center()
171 .border_1()
172 .rounded(rems(0.375))
173 .border_color(t.control_border())
174 .bg(t.surface)
175 .text_color(t.foreground)
176 .font_family(t.font.clone())
177 .text_size(rems(0.8125))
178 .line_height(gpui_kit::relative(1.2))
179 .input(
180 gpui_kit::div()
181 .flex_1()
182 .min_w_0()
183 .px(rems(0.375))
184 .child(BaseInput::new(state)),
185 )
186 .decrement_button(move |button| {
187 button
188 .w(rems(1.25))
189 .text_color(minus.foreground)
190 .bg(minus.control_fill())
191 .border_l_1()
192 .border_color(minus.divider())
193 .hover(|s| s.bg(minus.hover_fill()))
194 .child(crate::icon(crate::IconName::ChevronDown).size(rems(0.75)))
195 })
196 .increment_button(move |button| {
197 button
198 .w(rems(1.25))
199 .text_color(t.foreground)
200 .bg(t.control_fill())
201 .border_l_1()
202 .border_b_1()
203 .border_color(t.divider())
204 .hover(|s| s.bg(t.hover_fill()))
205 .child(crate::icon(crate::IconName::ChevronUp).size(rems(0.75)))
206 })
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use gpui_kit::{Context, Modifiers, Render, TestAppContext, prelude::*};
213
214 struct Harness(Entity<InputState>);
215
216 impl Render for Harness {
217 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
218 input("price", &self.0, window, cx)
219 .debug_selector(|| "price-frame".into())
220 .w(rems(15.))
221 .prefix(div().debug_selector(|| "price-prefix".into()).child("$"))
222 .suffix(div().debug_selector(|| "price-suffix".into()).child("USD"))
223 }
224 }
225
226 #[gpui_kit::test]
227 fn input_and_affixes_scale_with_the_window_rem(cx: &mut TestAppContext) {
228 cx.update(crate::init);
229 let (view, cx) = cx.add_window_view(|window, cx| {
230 Harness(cx.new(|cx| InputState::new(window, cx).default_value("181.170")))
231 });
232 let mut previous_suffix_width = gpui_kit::Pixels::ZERO;
233 for rem_size in [16., 20., 24.] {
234 cx.update(|window, cx| {
235 window.set_rem_size(gpui_kit::px(rem_size));
236 view.update(cx, |_, cx| cx.notify());
237 window.draw(cx).clear(cx);
238 });
239 let frame = cx.debug_bounds("price-frame").unwrap();
240 let prefix = cx.debug_bounds("price-prefix").unwrap();
241 let suffix = cx.debug_bounds("price-suffix").unwrap();
242 assert_eq!(frame.size.width, gpui_kit::px(15. * rem_size));
243 assert!(suffix.size.width > previous_suffix_width);
244 assert!(prefix.right() < suffix.left());
245 assert_eq!(
246 prefix.left() - frame.left(),
247 gpui_kit::px(rem_size * 0.375 + 1.),
248 "native field padding scales independently of its one-point border"
249 );
250 assert!(suffix.right() < frame.right());
251 previous_suffix_width = suffix.size.width;
252 }
253 }
254
255 #[gpui_kit::test]
256 fn affixes_preserve_editing_and_focus(cx: &mut TestAppContext) {
257 cx.update(crate::init);
258 let (view, cx) =
259 cx.add_window_view(|window, cx| Harness(cx.new(|cx| InputState::new(window, cx))));
260 cx.update(|window, cx| window.draw(cx).clear(cx));
261 let prefix = cx.debug_bounds("price-prefix").unwrap();
262 let suffix = cx.debug_bounds("price-suffix").unwrap();
263 assert!(prefix.right() < suffix.left());
264 cx.simulate_click(suffix.center(), Modifiers::default());
265 cx.update(|window, cx| {
266 assert!(view.read(cx).0.read(cx).focus_handle(cx).is_focused(window));
267 });
268 cx.simulate_input("181.170");
269 cx.update(|_, cx| {
270 assert_eq!(view.read(cx).0.read(cx).value().as_ref(), "181.170");
271 });
272 }
273
274 #[gpui_kit::test]
275 fn native_secure_mask_preserves_privacy_unicode_selection_and_ime(cx: &mut TestAppContext) {
276 use gpui_kit::{ClipboardItem, EntityInputHandler, Role, test::TestWindowExt};
277 struct SecureHarness(Entity<InputState>);
278 impl Render for SecureHarness {
279 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
280 input("native-secret", &self.0, window, cx).w(rems(10.625))
281 }
282 }
283 cx.update(crate::init);
284 let (view, cx) = cx.add_window_view(|window, cx| {
285 SecureHarness(cx.new(|cx| {
286 InputState::new(window, cx)
287 .masked(true)
288 .default_value("A猫🦀Z")
289 }))
290 });
291 cx.update(|window, cx| {
292 window.draw(cx).clear(cx);
293 assert_eq!(
294 window.find("native-secret").role(),
295 Some(Role::PasswordInput)
296 );
297 assert_eq!(window.find("native-secret").value(), None);
298 let state = view.read(cx).0.clone();
299 state.update(cx, |state, cx| {
300 assert_eq!(state.mask_character(), '●');
301 state.focus(window, cx);
302 state.select_all(window, cx);
303 cx.write_to_clipboard(ClipboardItem::new_string("sentinel".into()));
304 });
305 });
306 cx.simulate_keystrokes("cmd-c cmd-x");
307 cx.update(|window, cx| {
308 assert_eq!(
309 cx.read_from_clipboard().and_then(|item| item.text()),
310 Some("sentinel".into())
311 );
312 let state = view.read(cx).0.clone();
313 state.update(cx, |state, cx| {
314 assert_eq!(state.value(), "A猫🦀Z");
315 state.set_selected_range(1..8, cx);
316 state.replace_and_mark_text_in_range(None, "日本", Some(1..1), window, cx);
317 assert_eq!(state.value(), "A日本Z");
318 assert_eq!(state.selected_range(), 4..4);
319 });
320 window.draw(cx).clear(cx);
321 assert_eq!(window.find("native-secret").value(), None);
322 state.update(cx, |state, cx| {
323 state.replace_and_mark_text_in_range(None, "日本語", Some(3..3), window, cx);
324 assert_eq!(state.selected_range(), 10..10);
325 });
326 window.draw(cx).clear(cx);
327 state.update(cx, |state, cx| {
328 state.replace_text_in_range(None, "日🦀", window, cx);
329 assert_eq!(state.value(), "A日🦀Z");
330 assert_eq!(state.selected_range(), 8..8);
331 });
332 window.draw(cx).clear(cx);
333 assert_eq!(
334 window.find("native-secret").role(),
335 Some(Role::PasswordInput)
336 );
337 assert_eq!(window.find("native-secret").value(), None);
338 state.update(cx, |state, cx| state.set_masked(false, window, cx));
339 window.draw(cx).clear(cx);
340 assert_eq!(window.find("native-secret").role(), Some(Role::TextInput));
341 assert_eq!(state.read(cx).value(), "A日🦀Z");
342 });
343 }
344}