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
26struct 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 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#[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
106pub type Input = Field<InputMode>;
118
119pub type Textarea = Field<TextareaMode>;
135
136impl<M: InputModeKind> Field<M> {
137 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 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
157 self.id = id.into();
158 self
159 }
160
161 pub fn readonly(mut self, readonly: bool) -> Self {
164 self.readonly = Some(readonly);
165 self
166 }
167
168 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
170 self.accessibility_label = Some(label.into());
171 self
172 }
173
174 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 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 pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
215 self.prefix = Some(prefix.into_any_element());
216 self
217 }
218
219 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
221 self.suffix = Some(suffix.into_any_element());
222 self
223 }
224
225 pub fn cleanable(mut self, cleanable: bool) -> Self {
228 self.cleanable = cleanable;
229 self
230 }
231
232 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 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 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 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 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 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 .when(disabled, |this| {
393 this.capture_any_mouse_down(|_, window, _| window.prevent_default())
394 })
395 .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 .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 .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
457fn 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
466fn state_value<M: InputModeKind>(state: &Entity<InputBaseState<M>>, cx: &App) -> String {
468 state.read(cx).text().to_string()
469}