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