Skip to main content

gpui_cn/
input.rs

1use std::rc::Rc;
2
3use gpui_kit::{
4    AccessibleAction, AnyElement, App, Edges, ElementId, Entity, Hsla, InteractiveElement as _,
5    IntoElement, MouseButton, ParentElement as _, Pixels, RenderOnce, Role, SharedString,
6    StatefulInteractiveElement as _, StyleRefinement, Styled, TextAlign, Window,
7    accesskit::ActionData,
8    assets::IconName,
9    base::{
10        Disableable, StyledExt as _, TextStyleToken,
11        input::{
12            InputBase, InputBaseState, InputContextMenuCapabilities, InputEditorStyle, InputMode,
13            InputModeKind, TextareaMode,
14        },
15    },
16    div,
17    prelude::FluentBuilder as _,
18};
19
20use crate::{
21    ActiveTheme as _, Button, ButtonSize, MenuEntry, MenuState, Theme,
22    menu::{MenuPanels, TextMenuBuilder, open_text_menu},
23    touch_selection,
24};
25
26/// Everything the render needs from the theme, read in one borrow.
27struct Look {
28    fill: Hsla,
29    border: Hsla,
30    focus_border: Hsla,
31    foreground: Hsla,
32    placeholder: Hsla,
33    selection: Hsla,
34    radius: Pixels,
35    height: Pixels,
36    padding_x: Pixels,
37    padding_y: Pixels,
38    text: TextStyleToken,
39}
40
41impl Look {
42    /// A multi-line field takes the looser textarea text and the settings
43    /// textarea's side padding; a single line takes the search field's.
44    fn read<M: InputModeKind>(disabled: bool, cx: &App) -> Self {
45        let theme = cx.theme();
46        let metrics = &theme.metrics;
47        let strength = |color: Hsla| {
48            if disabled {
49                color.opacity(theme.disabled_opacity)
50            } else {
51                color
52            }
53        };
54        Self {
55            fill: strength(theme.field),
56            border: strength(theme.field_border),
57            focus_border: theme.field_focus_border,
58            foreground: theme.foreground(),
59            placeholder: theme.muted_foreground(),
60            selection: theme.selection(),
61            radius: theme.radius_md(),
62            height: metrics.field_height,
63            padding_x: if M::MULTI_LINE {
64                metrics.control_padding_md
65            } else {
66                metrics.field_padding_x
67            },
68            padding_y: metrics.field_padding_y,
69            text: if M::MULTI_LINE {
70                theme.text_textarea
71            } else {
72                theme.text_control
73            },
74        }
75    }
76}
77
78/// A shadcn-style text field on `gpui_base`'s editing engine, in the
79/// kind `M` names: [`Input`] for one line, [`Textarea`] for many.
80///
81/// The state owns the text, the caret, the selection, focus, the
82/// placeholder, the disabled and read-only flags, and the events. This
83/// type owns the look: the fill, the hairline that takes the focus color
84/// while the caret is inside, the height, and where a prefix and a
85/// suffix sit. Each render projects the theme's text colors onto the
86/// engine.
87///
88/// `Styled` refinements apply after the tokens, so they win.
89#[derive(IntoElement)]
90#[non_exhaustive]
91pub struct Field<M: InputModeKind> {
92    state: Entity<InputBaseState<M>>,
93    id: ElementId,
94    style: StyleRefinement,
95    disabled: Option<bool>,
96    readonly: Option<bool>,
97    accessibility_label: Option<SharedString>,
98    prefix: Option<AnyElement>,
99    suffix: Option<AnyElement>,
100    cleanable: bool,
101    mask_toggle: bool,
102    context_menu: Option<TextMenuBuilder>,
103    context_menu_enabled: bool,
104}
105
106/// A single-line text field on an [`InputState`](crate::InputState).
107///
108/// ```no_run
109/// use gpui_cn::{Input, InputState};
110/// use gpui_kit::{AppContext as _, Context, Window};
111///
112/// fn field(window: &mut Window, cx: &mut Context<()>) -> Input {
113///     let state = cx.new(|cx| InputState::new(window, cx).placeholder("Name"));
114///     Input::new(&state)
115/// }
116/// ```
117pub type Input = Field<InputMode>;
118
119/// A multi-line text field on a [`TextareaState`](crate::TextareaState).
120///
121/// The state's `auto_grow(min, max)` sets the height in rows, and
122/// `auto_grow(n, n)` fixes it at `n`. `Styled` on this type (`.h(..)`,
123/// `.min_h(..)`) refines the frame after the tokens.
124///
125/// ```no_run
126/// use gpui_cn::{Textarea, TextareaState};
127/// use gpui_kit::{AppContext as _, Context, Window};
128///
129/// fn field(window: &mut Window, cx: &mut Context<()>) -> Textarea {
130///     let state = cx.new(|cx| TextareaState::new(window, cx).auto_grow(3, 8));
131///     Textarea::new(&state)
132/// }
133/// ```
134pub type Textarea = Field<TextareaMode>;
135
136impl<M: InputModeKind> Field<M> {
137    /// A field on `state`. The id is derived from the state.
138    pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
139        Self {
140            state: state.clone(),
141            id: ("input", state.entity_id()).into(),
142            style: StyleRefinement::default(),
143            disabled: None,
144            readonly: None,
145            accessibility_label: None,
146            prefix: None,
147            suffix: None,
148            cleanable: false,
149            mask_toggle: false,
150            context_menu: None,
151            context_menu_enabled: true,
152        }
153    }
154
155    /// The frame's id, for tests and focus.
156    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
157        self.id = id.into();
158        self
159    }
160
161    /// Whether the text can be selected and copied but not changed.
162    /// Pushes onto the state; leave it unset to control the state directly.
163    pub fn readonly(mut self, readonly: bool) -> Self {
164        self.readonly = Some(readonly);
165        self
166    }
167
168    /// The name a screen reader announces. The placeholder is the fallback.
169    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
170        self.accessibility_label = Some(label.into());
171        self
172    }
173
174    /// Shapes the menu a right click opens: `build` gets the default rows
175    /// (Cut, Copy, Paste, a separator, Select All, enabled from what the
176    /// field can do at that moment) and returns the rows to show, the
177    /// defaults extended or replaced. No rows, no menu.
178    ///
179    /// ```no_run
180    /// use gpui_cn::{Input, InputState, MenuEntry, MenuItem};
181    /// use gpui_kit::Entity;
182    ///
183    /// fn field(state: &Entity<InputState>) -> Input {
184    ///     Input::new(state).context_menu(|mut entries, _, _, _| {
185    ///         entries.push(MenuEntry::Separator);
186    ///         entries.push(MenuItem::new("insert-date", "Insert Date").into());
187    ///         entries
188    ///     })
189    /// }
190    /// ```
191    pub fn context_menu(
192        mut self,
193        build: impl Fn(
194            Vec<MenuEntry>,
195            InputContextMenuCapabilities,
196            &mut Window,
197            &mut App,
198        ) -> Vec<MenuEntry>
199        + 'static,
200    ) -> Self {
201        self.context_menu = Some(Rc::new(build));
202        self
203    }
204
205    /// Whether a right click opens the field's menu. On by default.
206    pub fn context_menu_enabled(mut self, enabled: bool) -> Self {
207        self.context_menu_enabled = enabled;
208        self
209    }
210}
211
212impl Field<InputMode> {
213    /// An element before the text, inside the frame, such as an icon.
214    pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
215        self.prefix = Some(prefix.into_any_element());
216        self
217    }
218
219    /// An element after the text, inside the frame, such as a button.
220    pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
221        self.suffix = Some(suffix.into_any_element());
222        self
223    }
224
225    /// Whether a button after the text clears it, shown while the field
226    /// is editable and holds a value, as a search field does.
227    pub fn cleanable(mut self, cleanable: bool) -> Self {
228        self.cleanable = cleanable;
229        self
230    }
231
232    /// Whether a button after the text shows and hides a masked value.
233    pub fn mask_toggle(mut self, mask_toggle: bool) -> Self {
234        self.mask_toggle = mask_toggle;
235        self
236    }
237}
238
239impl<M: InputModeKind> Disableable for Field<M> {
240    /// Whether the field ignores input. Pushes onto the state; leave it
241    /// unset to control the state directly.
242    fn disabled(mut self, disabled: bool) -> Self {
243        self.disabled = Some(disabled);
244        self
245    }
246}
247
248impl<M: InputModeKind> Styled for Field<M> {
249    fn style(&mut self) -> &mut StyleRefinement {
250        &mut self.style
251    }
252}
253
254impl<M: InputModeKind> RenderOnce for Field<M> {
255    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
256        let pointer_cursors = Theme::global(cx).pointer_cursors;
257        let (disabled, readonly) = (self.disabled, self.readonly);
258        let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left);
259        let context_menu_enabled = self.context_menu_enabled;
260        let presentation = self.state.update(cx, |state, cx| {
261            state.set_context_menu_enabled(context_menu_enabled);
262            if let Some(disabled) = disabled {
263                state.set_disabled(disabled, cx);
264            }
265            if let Some(readonly) = readonly {
266                state.set_readonly(readonly, cx);
267            }
268            state.set_text_align(text_align, cx);
269            state.presentation()
270        });
271        let disabled = presentation.is_disabled();
272        let look = Look::read::<M>(disabled, cx);
273        let menu_id = ElementId::NamedChild(self.id.clone().into(), "context-menu".into());
274        let menu = window.use_keyed_state(menu_id.clone(), cx, |_, cx| MenuState::new(cx));
275        let menu_open = menu.read(cx).is_open();
276        // Base fills an unset selection from its `accent` token, which
277        // gpui-cn projects as the hover surface, so a selection would
278        // vanish into the fill.
279        self.state.update(cx, |state, _| {
280            state.set_editor_style(InputEditorStyle {
281                foreground: look.foreground,
282                muted_foreground: look.placeholder,
283                background: look.fill,
284                border: look.border,
285                selection: look.selection,
286                caret: look.foreground,
287                ..InputEditorStyle::default()
288            });
289            if M::MULTI_LINE {
290                // The text pads itself so its scrollbar and caret stay
291                // inside the hairline.
292                state.set_editor_paddings(Edges {
293                    top: look.padding_y,
294                    right: look.padding_x,
295                    bottom: look.padding_y,
296                    left: look.padding_x,
297                });
298            }
299        });
300
301        // The frame keeps its own focus handle so a control inside it,
302        // such as a suffix button, keeps the focused look.
303        let frame_handle = window
304            .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle())
305            .read(cx)
306            .clone();
307        let focused = !disabled && frame_handle.contains_focused(window, cx);
308        let placeholder = Some(presentation.placeholder().clone()).filter(|p| !p.is_empty());
309        let label = self.accessibility_label.or_else(|| placeholder.clone());
310        // Assistive technology reads the value from the frame; a masked
311        // value stays private. Outside an a11y session the rope is not
312        // copied, except in tests, which read the same value.
313        let value = ((window.is_a11y_active() || cfg!(feature = "test-support"))
314            && !presentation.is_masked())
315        .then(|| state_value(&self.state, cx));
316        let state = self.state;
317        let set_value = state.clone();
318        let touch_selection = touch_selection::for_state(&state, window, cx);
319        let clear = (self.cleanable
320            && presentation.is_editable()
321            && state.read(cx).text().len() > 0)
322            .then(|| {
323                let state = state.clone();
324                Button::new("clear")
325                    .ghost()
326                    .size(ButtonSize::Xs)
327                    .icon(IconName::CircleX)
328                    .accessibility_label("Clear")
329                    .tab_stop(false)
330                    .text_color(look.placeholder)
331                    .on_click(move |_, window, cx| {
332                        state.update(cx, |state, cx| {
333                            state.clean(window, cx);
334                            state.focus(window, cx);
335                        })
336                    })
337            });
338        let mask_toggle = self.mask_toggle.then(|| {
339            let state = state.clone();
340            let masked = presentation.is_masked();
341            Button::new("mask-toggle")
342                .ghost()
343                .size(ButtonSize::Xs)
344                .icon(if masked {
345                    IconName::Eye
346                } else {
347                    IconName::EyeOff
348                })
349                .accessibility_label(if masked { "Show value" } else { "Hide value" })
350                .tab_stop(false)
351                .text_color(look.placeholder)
352                .on_click(move |_, window, cx| {
353                    state.update(cx, |state, cx| state.toggle_masked(window, cx))
354                })
355        });
356        let suffix =
357            (self.suffix.is_some() || clear.is_some() || mask_toggle.is_some()).then(|| {
358                div()
359                    .flex()
360                    .items_center()
361                    .gap_1()
362                    .children(self.suffix)
363                    .children(mask_toggle)
364                    .children(clear)
365                    .into_any_element()
366            });
367
368        InputBase::new(self.id)
369            .focused(focused)
370            .disabled(disabled)
371            .track_focus(&frame_handle)
372            .role(if M::MULTI_LINE {
373                Role::MultilineTextInput
374            } else {
375                Role::TextInput
376            })
377            .when_some(label, |this, label| this.accessibility_label(label))
378            .when_some(placeholder, |this, placeholder| {
379                this.aria_placeholder(placeholder)
380            })
381            .when_some(value, |this, value| this.aria_value(value))
382            .when(presentation.is_editable(), |this| {
383                this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| {
384                    if let Some(ActionData::Value(value)) = data {
385                        let value = value.to_string();
386                        set_value.update(cx, |state, cx| state.replace_all(value, window, cx));
387                    }
388                })
389            })
390            // Base keeps a disabled field focusable; the frame refuses the
391            // click before it can move focus into the text.
392            .when(disabled, |this| {
393                this.capture_any_mouse_down(|_, window, _| window.prevent_default())
394            })
395            // The field opens its own menu: base's hook stays off while any
396            // popup is open. The press is the field's, not a context menu's
397            // around it.
398            .when(!disabled && context_menu_enabled, |this| {
399                let (menu, input, builder) = (menu.clone(), state.clone(), self.context_menu);
400                this.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
401                    .on_mouse_up(MouseButton::Right, move |event, window, cx| {
402                        open_text_menu(&menu, &input, event.position, builder.as_ref(), window, cx);
403                    })
404            })
405            // A click on the padding or an affix puts the caret in the
406            // text instead of focusing the frame. A control inside the
407            // frame that prevents default keeps the click.
408            .when(!disabled, |this| {
409                let state = state.clone();
410                this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
411                    if !window.default_prevented() {
412                        state.update(cx, |state, cx| state.focus(window, cx));
413                        window.prevent_default();
414                    }
415                })
416            })
417            .relative()
418            .flex()
419            .w_full()
420            .map(|this| {
421                if M::MULTI_LINE {
422                    this.flex_col().h_auto().min_h(look.height)
423                } else {
424                    this.items_center().h(look.height).px(look.padding_x)
425                }
426            })
427            .text_size(look.text.size)
428            .line_height(look.text.line_height)
429            .font_weight(look.text.weight)
430            .text_color(look.foreground)
431            .rounded(look.radius)
432            .bg(look.fill)
433            .border_1()
434            .border_color(look.border)
435            .styles(|styles| styles.focused(|style| style.border_color(look.focus_border)))
436            .map(|this| {
437                if presentation.is_editable() && pointer_cursors {
438                    this.cursor_text()
439                } else {
440                    this.cursor_default()
441                }
442            })
443            .refine_style(&self.style)
444            // The reference app's search field leaves 12px between the
445            // icon and the text, measured at 2x.
446            .gap_3()
447            .children(self.prefix.map(|prefix| affix(prefix, disabled, cx)))
448            .child(div().relative().flex().flex_1().min_w_0().child(state))
449            .children(suffix.map(|suffix| affix(suffix, disabled, cx)))
450            .children(touch_selection)
451            .when(menu_open, |this| {
452                this.child(MenuPanels::new(menu_id, &menu))
453            })
454    }
455}
456
457/// A prefix or suffix, at the disabled strength with the field.
458fn affix(element: AnyElement, disabled: bool, cx: &App) -> impl IntoElement {
459    div()
460        .flex()
461        .flex_shrink_0()
462        .when(disabled, |this| this.opacity(cx.theme().disabled_opacity))
463        .child(element)
464}
465
466/// The text as a string, for assistive technology.
467fn state_value<M: InputModeKind>(state: &Entity<InputBaseState<M>>, cx: &App) -> String {
468    state.read(cx).text().to_string()
469}