Skip to main content

gpui_macos/
input.rs

1//! Native text editing, selection, clipboard and IME supplied by gpui-base.
2use 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/// A native single-line editor with optional, non-editable prefix and suffix slots.
16/// The caller retains the `InputState`; affixes never become part of its value.
17#[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                    // AppKit's secure cell draws an 8-point advance and a
69                    // 5.5-point dot at regular size. This public font's filled
70                    // circle matches that raster at 12.8 points. Apply it only
71                    // to masked text; placeholders and affixes retain SF.
72                    .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
155/// Numeric editing with native arrow-key stepping and stacked arrow buttons.
156pub 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}