1use gpui_base::TestSupportExt as _;
12
13use gpui::{
14 AnyElement, App, ElementId, InteractiveElement, Interactivity, IntoElement, MouseButton,
15 ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
16 StyleRefinement, Styled, ViewElement, Window, div, prelude::FluentBuilder as _, px, rems,
17};
18
19use crate::{
20 ActiveTheme as _, Disableable, FocusableExt as _, Icon, Selectable, Sizable, Size,
21 StyleSized as _, StyledExt as _,
22 button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants},
23 h_flex,
24 input::{Input, Textarea},
25 v_flex,
26};
27
28#[derive(IntoElement)]
34pub struct InputGroup {
35 id: ElementId,
36 style: StyleRefinement,
37 control: Option<Input>,
38 addons: Vec<InputGroupAddon>,
39 size: Size,
40 disabled: bool,
41 readonly: bool,
42 invalid: bool,
43 focus_ring: bool,
44 aria_label: Option<SharedString>,
45}
46
47impl InputGroup {
48 pub fn new(id: impl Into<ElementId>) -> Self {
49 Self {
50 id: id.into(),
51 style: StyleRefinement::default(),
52 control: None,
53 addons: Vec::new(),
54 size: Size::default(),
55 disabled: false,
56 readonly: false,
57 invalid: false,
58 focus_ring: true,
59 aria_label: None,
60 }
61 }
62
63 pub fn input(mut self, input: impl Into<InputGroupControl>) -> Self {
65 self.control = Some(input.into().0);
66 self
67 }
68
69 pub fn addon(mut self, addon: InputGroupAddon) -> Self {
71 self.addons.push(addon);
72 self
73 }
74
75 pub fn disabled(mut self, disabled: bool) -> Self {
77 self.disabled = disabled;
78 self
79 }
80
81 pub fn readonly(mut self, readonly: bool) -> Self {
83 self.readonly = readonly;
84 self
85 }
86
87 pub fn invalid(mut self, invalid: bool) -> Self {
89 self.invalid = invalid;
90 self
91 }
92
93 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
95 self.aria_label = Some(label.into());
96 self
97 }
98}
99
100impl Sizable for InputGroup {
101 fn with_size(mut self, size: impl Into<Size>) -> Self {
102 self.size = size.into();
103 self
104 }
105}
106
107impl crate::FocusableExt for InputGroup {
108 fn focus_ring(mut self, enabled: bool) -> Self {
109 self.focus_ring = enabled;
110 self
111 }
112
113 fn is_focus_ring_enabled(&self) -> bool {
114 self.focus_ring
115 }
116}
117
118impl Styled for InputGroup {
119 fn style(&mut self) -> &mut StyleRefinement {
120 &mut self.style
121 }
122}
123
124#[derive(Clone, Copy, Default)]
126struct GroupPresentation {
127 size: Size,
128 disabled: bool,
129 readonly: bool,
130 inline_start: bool,
131 inline_end: bool,
132 block_start: bool,
133 block_end: bool,
134}
135
136impl RenderOnce for InputGroup {
137 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
138 let state = self.control.as_ref().map(|input| input.state().clone());
139 let disabled = self.disabled
140 || self
141 .control
142 .as_ref()
143 .is_some_and(|input| input.is_disabled());
144 let focused = !disabled
145 && state
146 .as_ref()
147 .is_some_and(|state| state.presentation(cx).focus_handle().is_focused(window));
148 let multiline = state
149 .as_ref()
150 .is_some_and(|state| state.presentation(cx).is_multi_line());
151 let has = |alignment| self.addons.iter().any(|addon| addon.alignment == alignment);
152 let presentation = GroupPresentation {
153 size: self.size,
154 disabled,
155 readonly: self.readonly,
156 inline_start: has(InputGroupAddonAlignment::InlineStart),
157 inline_end: has(InputGroupAddonAlignment::InlineEnd),
158 block_start: has(InputGroupAddonAlignment::BlockStart),
159 block_end: has(InputGroupAddonAlignment::BlockEnd),
160 };
161 let mut inline_start = Vec::new();
162 let mut inline_end = Vec::new();
163 let mut block_start = Vec::new();
164 let mut block_end = Vec::new();
165 for addon in self.addons {
166 let target = match addon.alignment {
167 InputGroupAddonAlignment::InlineStart => &mut inline_start,
168 InputGroupAddonAlignment::InlineEnd => &mut inline_end,
169 InputGroupAddonAlignment::BlockStart => &mut block_start,
170 InputGroupAddonAlignment::BlockEnd => &mut block_end,
171 };
172 target.push(addon.render_in_group(presentation, window, cx));
173 }
174 let control = self
175 .control
176 .map(|input| render_control(input, presentation, multiline));
177 let theme = cx.theme();
178 let appearance = GroupAppearance::new(theme, focused, disabled, self.invalid);
179 let radius = theme.radius;
180 let foreground = theme.foreground;
181 let show_ring = theme.focus_ring && self.focus_ring;
182 let motion = theme.motion_tokens();
183 let duration = motion.duration_fast;
184 let easing = motion.easing_move.clone();
185 let (border, background) = window.with_id(self.id.clone(), |window| {
188 let transition = || gpui_base::Transition::new(duration).easing(easing.clone());
189 (
190 gpui_base::transition("border-color", appearance.border, transition(), window, cx),
191 gpui_base::transition(
192 "background-color",
193 appearance.background,
194 transition(),
195 window,
196 cx,
197 ),
198 )
199 });
200 v_flex()
201 .id(self.id)
202 .test_support()
203 .role(Role::Group)
204 .when_some(self.aria_label, |this, label| this.aria_label(label))
205 .relative()
206 .w_full()
207 .min_w_0()
208 .when(
209 !multiline && !presentation.block_start && !presentation.block_end,
210 |this| this.input_h(self.size),
211 )
212 .rounded(radius)
213 .border_1()
214 .border_color(border)
215 .bg(background)
216 .text_color(foreground)
217 .input_text_size(self.size)
218 .shadow_none()
219 .when(disabled, |this| {
220 this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
222 .capture_key_down(|event, _, cx| {
223 if event.keystroke.key != "tab" {
224 cx.stop_propagation();
225 }
226 })
227 })
228 .refine_style(&self.style)
229 .when(disabled, |this| this.bg(background).opacity(0.5))
230 .when(appearance.ring.is_some(), |this| this.border_color(border))
231 .when_some(appearance.ring.filter(|_| show_ring), |this, ring| {
232 crate::styled::focus_ring(this, window, ring)
233 })
234 .when_some(state.filter(|_| !disabled), |this, state| {
235 this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
236 if !window.default_prevented() {
239 state.focus(window, cx);
240 window.prevent_default();
241 }
242 })
243 })
244 .children(block_start)
245 .child(
246 h_flex()
247 .w_full()
248 .min_w_0()
249 .items_center()
250 .when(
251 !multiline && !presentation.block_start && !presentation.block_end,
252 |this| this.h_full(),
253 )
254 .children(inline_start)
255 .children(control)
256 .children(inline_end),
257 )
258 .children(block_end)
259 }
260}
261
262fn render_control(
266 mut input: Input,
267 presentation: GroupPresentation,
268 multiline: bool,
269) -> AnyElement {
270 let style = std::mem::take(input.style());
271 input
272 .with_size(presentation.size)
273 .appearance(false)
274 .focus_bordered(false)
275 .disabled(presentation.disabled)
276 .readonly(presentation.readonly)
277 .flex_1()
278 .min_w_0()
279 .when(multiline, |this| this.min_h_16())
280 .when(!multiline, |this| {
281 this.when(presentation.inline_start, |this| this.pl_2())
282 .when(presentation.inline_end, |this| this.pr_2())
283 })
284 .refine_style(&style)
285 .into_any_element()
286}
287
288struct GroupAppearance {
289 background: gpui::Hsla,
290 border: gpui::Hsla,
291 ring: Option<gpui::Hsla>,
292}
293
294impl GroupAppearance {
295 fn new(theme: &crate::Theme, focused: bool, disabled: bool, invalid: bool) -> Self {
296 let background = if disabled {
297 theme.input.opacity(if theme.is_dark() { 0.8 } else { 0.5 })
298 } else if theme.is_dark() {
299 theme.input.opacity(0.3)
300 } else {
301 theme.transparent
302 };
303 let (border, ring) = if invalid {
306 (
307 theme.danger,
308 Some(
309 theme
310 .danger
311 .opacity(if theme.is_dark() { 0.4 } else { 0.2 }),
312 ),
313 )
314 } else if focused && !disabled {
315 (theme.ring, Some(theme.ring.opacity(0.5)))
316 } else {
317 (theme.input, None)
318 };
319 Self {
320 background,
321 border,
322 ring,
323 }
324 }
325}
326
327#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
329pub enum InputGroupAddonAlignment {
330 #[default]
331 InlineStart,
332 InlineEnd,
333 BlockStart,
334 BlockEnd,
335}
336
337#[derive(IntoElement)]
342pub struct InputGroupAddon {
343 id: ElementId,
344 alignment: InputGroupAddonAlignment,
345 style: StyleRefinement,
346 children: Vec<AnyElement>,
347}
348
349impl InputGroupAddon {
350 pub fn new(id: impl Into<ElementId>) -> Self {
351 Self {
352 id: id.into(),
353 alignment: InputGroupAddonAlignment::default(),
354 style: StyleRefinement::default(),
355 children: Vec::new(),
356 }
357 }
358
359 pub fn align(mut self, alignment: InputGroupAddonAlignment) -> Self {
360 self.alignment = alignment;
361 self
362 }
363
364 fn render_in_group(
365 mut self,
366 presentation: GroupPresentation,
367 window: &mut Window,
368 cx: &mut App,
369 ) -> AnyElement {
370 for child in &mut self.children {
371 if let Some(button) = button_element::InputGroupButtonElement::from_element(child) {
372 button.disable(presentation.disabled);
373 }
374 }
375 let border_top = self
376 .style
377 .border_widths
378 .top
379 .is_some_and(|width| width.to_pixels(window.rem_size()) > px(0.));
380 let border_bottom = self
381 .style
382 .border_widths
383 .bottom
384 .is_some_and(|width| width.to_pixels(window.rem_size()) > px(0.));
385 let block_px = presentation.size.input_px();
390 h_flex()
391 .id(self.id)
392 .test_support()
393 .flex_none()
394 .gap_2()
395 .py_1p5()
396 .when(
397 matches!(presentation.size, Size::XSmall | Size::Small),
398 |this| this.py_0(),
399 )
400 .justify_center()
401 .font_medium()
402 .input_text_size(presentation.size)
403 .text_color(cx.theme().muted_foreground)
404 .cursor_text()
405 .map(|this| match self.alignment {
406 InputGroupAddonAlignment::InlineStart => this.pl_1p5(),
407 InputGroupAddonAlignment::InlineEnd => this.pr_1p5(),
408 InputGroupAddonAlignment::BlockStart => this
409 .w_full()
410 .justify_start()
411 .px(block_px)
412 .pt_2()
413 .when(border_bottom, |this| this.pb_2()),
414 InputGroupAddonAlignment::BlockEnd => this
415 .w_full()
416 .justify_start()
417 .px(block_px)
418 .pb_2()
419 .when(border_top, |this| this.pt_2()),
420 })
421 .refine_style(&self.style)
422 .children(self.children.into_iter().map(addon_child))
423 .into_any_element()
424 }
425}
426
427impl ParentElement for InputGroupAddon {
428 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
429 self.children.extend(elements);
430 }
431}
432
433impl Styled for InputGroupAddon {
434 fn style(&mut self) -> &mut StyleRefinement {
435 &mut self.style
436 }
437}
438
439impl RenderOnce for InputGroupAddon {
440 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
441 self.render_in_group(GroupPresentation::default(), window, cx)
442 }
443}
444
445pub type InputGroupInput = Input;
448
449pub type InputGroupTextarea = Textarea;
452
453pub struct InputGroupControl(Input);
455
456impl From<Input> for InputGroupControl {
457 fn from(input: Input) -> Self {
458 Self(input)
459 }
460}
461
462impl From<Textarea> for InputGroupControl {
463 fn from(textarea: Textarea) -> Self {
464 Self(textarea.into_input())
465 }
466}
467
468pub struct InputGroupButton {
473 button: Button,
474 size: Size,
475 style: StyleRefinement,
476}
477
478impl InputGroupButton {
479 pub fn new(id: impl Into<ElementId>) -> Self {
480 Self {
481 button: Button::new(id).ghost(),
482 size: Size::XSmall,
483 style: StyleRefinement::default(),
484 }
485 }
486
487 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
488 self.button = self.button.label(label);
489 self
490 }
491
492 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
493 self.button = self.button.icon(icon.into());
494 self
495 }
496
497 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
498 self.button = self.button.accessibility_label(label);
499 self
500 }
501
502 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
503 self.button = self.button.tooltip(tooltip);
504 self
505 }
506
507 pub fn loading(mut self, loading: bool) -> Self {
508 self.button = self.button.loading(loading);
509 self
510 }
511
512 pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
513 self.button = self.button.loading_icon(icon);
514 self
515 }
516
517 pub fn outline(mut self) -> Self {
518 self.button = self.button.outline();
519 self
520 }
521
522 pub fn tab_index(mut self, tab_index: isize) -> Self {
523 self.button = self.button.tab_index(tab_index);
524 self
525 }
526
527 pub fn dropdown_caret(mut self, dropdown_caret: bool) -> Self {
528 self.button = self.button.dropdown_caret(dropdown_caret);
529 self
530 }
531
532 pub fn on_click(
533 mut self,
534 handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
535 ) -> Self {
536 self.button = self.button.on_click(handler);
537 self
538 }
539
540 fn render_in_group(self, disabled: bool, window: &mut Window, cx: &mut App) -> AnyElement {
541 let disabled = disabled || self.button.is_disabled();
542 let selected = self.button.is_selected();
543 let icon_only = self.button.is_icon_only();
544 let ghost =
545 matches!(self.button.variant(), ButtonVariant::Ghost) && !self.button.is_outline();
546 let muted = cx.theme().muted;
547 let hover = muted.opacity(if cx.theme().is_dark() { 0.5 } else { 1. });
548 let compact = matches!(self.size, Size::XSmall | Size::Small);
549 let icon_size = match self.size {
550 Size::XSmall => Size::Small,
551 Size::Small => Size::Medium,
552 size => size,
553 };
554 let content_style = div()
555 .text_sm()
556 .line_height(rems(1.25))
557 .map(|this| match self.size {
558 Size::XSmall => this.gap_1(),
559 _ => this.gap_1p5(),
560 })
561 .style()
562 .clone();
563 self.button
564 .when(disabled, |this| this.disabled(true))
565 .map(|this| {
566 if compact {
567 this.with_size(Size::Medium)
568 .content_style(content_style, icon_size)
569 } else {
570 this.with_size(self.size)
571 }
572 })
573 .when(ghost, |this| {
574 this.custom(
575 ButtonCustomVariant::new(cx)
576 .color(cx.theme().transparent)
577 .foreground(cx.theme().foreground)
578 .hover(hover)
579 .active(if selected { muted } else { hover }),
580 )
581 .text_color(cx.theme().foreground)
582 .when(disabled, |this| this.opacity(0.5))
583 })
584 .when(disabled, |this| this.focus_ring(false))
585 .text_sm()
586 .font_medium()
587 .border_1()
588 .shadow_none()
589 .map(|this| match (self.size, icon_only) {
590 (Size::XSmall, false) => this.h_6().px_2().rounded(cx.theme().radius_tokens().sm),
591 (Size::XSmall, true) => this.size_6().p_0().rounded(cx.theme().radius_tokens().sm),
592 (Size::Small, false) => this.h_8().px_2p5().rounded(cx.theme().radius),
593 (Size::Small, true) => this.size_8().p_0().rounded(cx.theme().radius),
594 _ => this,
595 })
596 .refine_style(&self.style)
597 .render(window, cx)
598 .into_any_element()
599 }
600}
601
602impl Sizable for InputGroupButton {
603 fn with_size(mut self, size: impl Into<Size>) -> Self {
604 self.size = size.into();
605 self
606 }
607}
608
609impl ButtonVariants for InputGroupButton {
610 fn with_variant(mut self, variant: ButtonVariant) -> Self {
611 self.button = self.button.with_variant(variant);
612 self
613 }
614}
615
616impl Disableable for InputGroupButton {
617 fn disabled(mut self, disabled: bool) -> Self {
618 self.button = self.button.disabled(disabled);
619 self
620 }
621}
622
623impl Selectable for InputGroupButton {
624 fn selected(mut self, selected: bool) -> Self {
625 self.button = self.button.selected(selected);
626 self
627 }
628
629 fn is_selected(&self) -> bool {
630 self.button.is_selected()
631 }
632}
633
634impl InteractiveElement for InputGroupButton {
635 fn interactivity(&mut self) -> &mut Interactivity {
636 self.button.interactivity()
637 }
638}
639
640impl crate::menu::DropdownMenu for InputGroupButton {}
641
642impl IntoElement for InputGroupButton {
643 type Element = AnyElement;
644
645 fn into_element(self) -> Self::Element {
646 button_element::InputGroupButtonElement::new(self).into_any_element()
647 }
648
649 fn into_any_element(self) -> AnyElement {
650 self.into_element()
651 }
652}
653
654impl Styled for InputGroupButton {
655 fn style(&mut self) -> &mut StyleRefinement {
656 &mut self.style
657 }
658}
659
660impl ParentElement for InputGroupButton {
661 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
662 self.button.extend(elements);
663 }
664}
665
666impl RenderOnce for InputGroupButton {
667 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
668 self.render_in_group(false, window, cx)
669 }
670}
671
672#[derive(IntoElement, Default)]
674pub struct InputGroupText {
675 style: StyleRefinement,
676 children: Vec<AnyElement>,
677}
678
679impl InputGroupText {
680 pub fn new() -> Self {
681 Self::default()
682 }
683}
684
685impl ParentElement for InputGroupText {
686 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
687 self.children.extend(elements);
688 }
689}
690
691impl Styled for InputGroupText {
692 fn style(&mut self) -> &mut StyleRefinement {
693 &mut self.style
694 }
695}
696
697impl RenderOnce for InputGroupText {
698 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
699 h_flex()
700 .gap_2()
701 .text_sm()
702 .text_color(cx.theme().muted_foreground)
703 .refine_style(&self.style)
704 .children(self.children.into_iter().map(addon_child))
705 }
706}
707
708fn addon_child(mut child: AnyElement) -> AnyElement {
709 if button_element::unwrapped_element(&mut child)
710 .downcast_mut::<ViewElement<Icon>>()
711 .is_some()
712 {
713 div()
716 .flex_none()
717 .text_base()
718 .child(child)
719 .into_any_element()
720 } else {
721 child
722 }
723}
724
725#[cfg(test)]
726mod tests;
727
728mod button_element;