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| {
191 let transition = || {
192 let easing = easing.clone();
193 gpui_base::Transition::new(duration)
194 .ease(move |progress| easing.sample(progress).clamp(0., 1.))
195 };
196 (
197 gpui_base::transition("border-color", appearance.border, transition(), window, cx),
198 gpui_base::transition(
199 "background-color",
200 appearance.background,
201 transition(),
202 window,
203 cx,
204 ),
205 )
206 });
207 v_flex()
208 .id(self.id)
209 .test_support()
210 .role(Role::Group)
211 .when_some(self.aria_label, |this, label| this.aria_label(label))
212 .relative()
213 .w_full()
214 .min_w_0()
215 .when(
216 !multiline && !presentation.block_start && !presentation.block_end,
217 |this| this.input_h(self.size),
218 )
219 .rounded(radius)
220 .border_1()
221 .border_color(border)
222 .bg(background)
223 .text_color(foreground)
224 .input_text_size(self.size)
225 .shadow_none()
226 .when(disabled, |this| {
227 this.capture_any_mouse_down(|_, _, cx| cx.stop_propagation())
229 .capture_key_down(|event, _, cx| {
230 if event.keystroke.key != "tab" {
231 cx.stop_propagation();
232 }
233 })
234 })
235 .refine_style(&self.style)
236 .when(disabled, |this| this.bg(background).opacity(0.5))
237 .when(appearance.ring.is_some(), |this| this.border_color(border))
238 .when_some(appearance.ring.filter(|_| show_ring), |this, ring| {
239 crate::styled::focus_ring(this, window, ring)
240 })
241 .when_some(state.filter(|_| !disabled), |this, state| {
242 this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
243 if !window.default_prevented() {
246 state.focus(window, cx);
247 window.prevent_default();
248 }
249 })
250 })
251 .children(block_start)
252 .child(
253 h_flex()
254 .w_full()
255 .min_w_0()
256 .items_center()
257 .when(
258 !multiline && !presentation.block_start && !presentation.block_end,
259 |this| this.h_full(),
260 )
261 .children(inline_start)
262 .children(control)
263 .children(inline_end),
264 )
265 .children(block_end)
266 }
267}
268
269fn render_control(
273 mut input: Input,
274 presentation: GroupPresentation,
275 multiline: bool,
276) -> AnyElement {
277 let style = std::mem::take(input.style());
278 input
279 .with_size(presentation.size)
280 .appearance(false)
281 .focus_bordered(false)
282 .disabled(presentation.disabled)
283 .readonly(presentation.readonly)
284 .flex_1()
285 .min_w_0()
286 .when(multiline, |this| this.min_h_16())
287 .when(!multiline, |this| {
288 this.when(presentation.inline_start, |this| this.pl_2())
289 .when(presentation.inline_end, |this| this.pr_2())
290 })
291 .refine_style(&style)
292 .into_any_element()
293}
294
295struct GroupAppearance {
296 background: gpui::Hsla,
297 border: gpui::Hsla,
298 ring: Option<gpui::Hsla>,
299}
300
301impl GroupAppearance {
302 fn new(theme: &crate::Theme, focused: bool, disabled: bool, invalid: bool) -> Self {
303 let background = if disabled {
304 theme.input.opacity(if theme.is_dark() { 0.8 } else { 0.5 })
305 } else if theme.is_dark() {
306 theme.input.opacity(0.3)
307 } else {
308 theme.transparent
309 };
310 let (border, ring) = if invalid {
313 (
314 theme.danger,
315 Some(
316 theme
317 .danger
318 .opacity(if theme.is_dark() { 0.4 } else { 0.2 }),
319 ),
320 )
321 } else if focused && !disabled {
322 (theme.ring, Some(theme.ring.opacity(0.5)))
323 } else {
324 (theme.input, None)
325 };
326 Self {
327 background,
328 border,
329 ring,
330 }
331 }
332}
333
334#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
336pub enum InputGroupAddonAlignment {
337 #[default]
338 InlineStart,
339 InlineEnd,
340 BlockStart,
341 BlockEnd,
342}
343
344#[derive(IntoElement)]
349pub struct InputGroupAddon {
350 id: ElementId,
351 alignment: InputGroupAddonAlignment,
352 style: StyleRefinement,
353 children: Vec<AnyElement>,
354}
355
356impl InputGroupAddon {
357 pub fn new(id: impl Into<ElementId>) -> Self {
358 Self {
359 id: id.into(),
360 alignment: InputGroupAddonAlignment::default(),
361 style: StyleRefinement::default(),
362 children: Vec::new(),
363 }
364 }
365
366 pub fn align(mut self, alignment: InputGroupAddonAlignment) -> Self {
367 self.alignment = alignment;
368 self
369 }
370
371 fn render_in_group(
372 mut self,
373 presentation: GroupPresentation,
374 window: &mut Window,
375 cx: &mut App,
376 ) -> AnyElement {
377 for child in &mut self.children {
378 if let Some(button) = button_element::InputGroupButtonElement::from_element(child) {
379 button.disable(presentation.disabled);
380 }
381 }
382 let border_top = self
383 .style
384 .border_widths
385 .top
386 .is_some_and(|width| width.to_pixels(window.rem_size()) > px(0.));
387 let border_bottom = self
388 .style
389 .border_widths
390 .bottom
391 .is_some_and(|width| width.to_pixels(window.rem_size()) > px(0.));
392 let block_px = presentation.size.input_px();
397 h_flex()
398 .id(self.id)
399 .test_support()
400 .flex_none()
401 .gap_2()
402 .py_1p5()
403 .when(
404 matches!(presentation.size, Size::XSmall | Size::Small),
405 |this| this.py_0(),
406 )
407 .justify_center()
408 .font_medium()
409 .input_text_size(presentation.size)
410 .text_color(cx.theme().muted_foreground)
411 .cursor_text()
412 .map(|this| match self.alignment {
413 InputGroupAddonAlignment::InlineStart => this.pl_1p5(),
414 InputGroupAddonAlignment::InlineEnd => this.pr_1p5(),
415 InputGroupAddonAlignment::BlockStart => this
416 .w_full()
417 .justify_start()
418 .px(block_px)
419 .pt_2()
420 .when(border_bottom, |this| this.pb_2()),
421 InputGroupAddonAlignment::BlockEnd => this
422 .w_full()
423 .justify_start()
424 .px(block_px)
425 .pb_2()
426 .when(border_top, |this| this.pt_2()),
427 })
428 .refine_style(&self.style)
429 .children(self.children.into_iter().map(addon_child))
430 .into_any_element()
431 }
432}
433
434impl ParentElement for InputGroupAddon {
435 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
436 self.children.extend(elements);
437 }
438}
439
440impl Styled for InputGroupAddon {
441 fn style(&mut self) -> &mut StyleRefinement {
442 &mut self.style
443 }
444}
445
446impl RenderOnce for InputGroupAddon {
447 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
448 self.render_in_group(GroupPresentation::default(), window, cx)
449 }
450}
451
452pub type InputGroupInput = Input;
455
456pub type InputGroupTextarea = Textarea;
459
460pub struct InputGroupControl(Input);
462
463impl From<Input> for InputGroupControl {
464 fn from(input: Input) -> Self {
465 Self(input)
466 }
467}
468
469impl From<Textarea> for InputGroupControl {
470 fn from(textarea: Textarea) -> Self {
471 Self(textarea.into_input())
472 }
473}
474
475pub struct InputGroupButton {
480 button: Button,
481 size: Size,
482 style: StyleRefinement,
483}
484
485impl InputGroupButton {
486 pub fn new(id: impl Into<ElementId>) -> Self {
487 Self {
488 button: Button::new(id).ghost(),
489 size: Size::XSmall,
490 style: StyleRefinement::default(),
491 }
492 }
493
494 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
495 self.button = self.button.label(label);
496 self
497 }
498
499 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
500 self.button = self.button.icon(icon.into());
501 self
502 }
503
504 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
505 self.button = self.button.accessibility_label(label);
506 self
507 }
508
509 pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
510 self.button = self.button.tooltip(tooltip);
511 self
512 }
513
514 pub fn tooltip_show_delay(mut self, delay: Duration) -> Self {
515 self.button = self.button.tooltip_show_delay(delay);
516 self
517 }
518
519 pub fn loading(mut self, loading: bool) -> Self {
520 self.button = self.button.loading(loading);
521 self
522 }
523
524 pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
525 self.button = self.button.loading_icon(icon);
526 self
527 }
528
529 pub fn outline(mut self) -> Self {
530 self.button = self.button.outline();
531 self
532 }
533
534 pub fn tab_index(mut self, tab_index: isize) -> Self {
535 self.button = self.button.tab_index(tab_index);
536 self
537 }
538
539 pub fn dropdown_caret(mut self, dropdown_caret: bool) -> Self {
540 self.button = self.button.dropdown_caret(dropdown_caret);
541 self
542 }
543
544 pub fn on_click(
545 mut self,
546 handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
547 ) -> Self {
548 self.button = self.button.on_click(handler);
549 self
550 }
551
552 fn render_in_group(self, disabled: bool, window: &mut Window, cx: &mut App) -> AnyElement {
553 let disabled = disabled || self.button.is_disabled();
554 let selected = self.button.is_selected();
555 let icon_only = self.button.is_icon_only();
556 let ghost =
557 matches!(self.button.variant(), ButtonVariant::Ghost) && !self.button.is_outline();
558 let muted = cx.theme().muted;
559 let hover = muted.opacity(if cx.theme().is_dark() { 0.5 } else { 1. });
560 let compact = matches!(self.size, Size::XSmall | Size::Small);
561 let icon_size = match self.size {
562 Size::XSmall => Size::Small,
563 Size::Small => Size::Medium,
564 size => size,
565 };
566 let content_style = div()
567 .text_sm()
568 .line_height(rems(1.25))
569 .map(|this| match self.size {
570 Size::XSmall => this.gap_1(),
571 _ => this.gap_1p5(),
572 })
573 .style()
574 .clone();
575 self.button
576 .when(disabled, |this| this.disabled(true))
577 .map(|this| {
578 if compact {
579 this.with_size(Size::Medium)
580 .content_style(content_style, icon_size)
581 } else {
582 this.with_size(self.size)
583 }
584 })
585 .when(ghost, |this| {
586 this.custom(
587 ButtonCustomVariant::new(cx)
588 .color(cx.theme().transparent)
589 .foreground(cx.theme().foreground)
590 .hover(hover)
591 .active(if selected { muted } else { hover }),
592 )
593 .text_color(cx.theme().foreground)
594 .when(disabled, |this| this.opacity(0.5))
595 })
596 .when(disabled, |this| this.focus_ring(false))
597 .text_sm()
598 .font_medium()
599 .border_1()
600 .shadow_none()
601 .map(|this| match (self.size, icon_only) {
602 (Size::XSmall, false) => this.h_6().px_2().rounded(cx.theme().radius_tokens().sm),
603 (Size::XSmall, true) => this.size_6().p_0().rounded(cx.theme().radius_tokens().sm),
604 (Size::Small, false) => this.h_8().px_2p5().rounded(cx.theme().radius),
605 (Size::Small, true) => this.size_8().p_0().rounded(cx.theme().radius),
606 _ => this,
607 })
608 .refine_style(&self.style)
609 .render(window, cx)
610 .into_any_element()
611 }
612}
613
614impl Sizable for InputGroupButton {
615 fn with_size(mut self, size: impl Into<Size>) -> Self {
616 self.size = size.into();
617 self
618 }
619}
620
621impl ButtonVariants for InputGroupButton {
622 fn with_variant(mut self, variant: ButtonVariant) -> Self {
623 self.button = self.button.with_variant(variant);
624 self
625 }
626}
627
628impl Disableable for InputGroupButton {
629 fn disabled(mut self, disabled: bool) -> Self {
630 self.button = self.button.disabled(disabled);
631 self
632 }
633}
634
635impl Selectable for InputGroupButton {
636 fn selected(mut self, selected: bool) -> Self {
637 self.button = self.button.selected(selected);
638 self
639 }
640
641 fn is_selected(&self) -> bool {
642 self.button.is_selected()
643 }
644}
645
646impl InteractiveElement for InputGroupButton {
647 fn interactivity(&mut self) -> &mut Interactivity {
648 self.button.interactivity()
649 }
650}
651
652impl crate::menu::DropdownMenu for InputGroupButton {}
653
654impl IntoElement for InputGroupButton {
655 type Element = AnyElement;
656
657 fn into_element(self) -> Self::Element {
658 button_element::InputGroupButtonElement::new(self).into_any_element()
659 }
660
661 fn into_any_element(self) -> AnyElement {
662 self.into_element()
663 }
664}
665
666impl Styled for InputGroupButton {
667 fn style(&mut self) -> &mut StyleRefinement {
668 &mut self.style
669 }
670}
671
672impl ParentElement for InputGroupButton {
673 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
674 self.button.extend(elements);
675 }
676}
677
678impl RenderOnce for InputGroupButton {
679 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
680 self.render_in_group(false, window, cx)
681 }
682}
683
684#[derive(IntoElement, Default)]
686pub struct InputGroupText {
687 style: StyleRefinement,
688 children: Vec<AnyElement>,
689}
690
691impl InputGroupText {
692 pub fn new() -> Self {
693 Self::default()
694 }
695}
696
697impl ParentElement for InputGroupText {
698 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
699 self.children.extend(elements);
700 }
701}
702
703impl Styled for InputGroupText {
704 fn style(&mut self) -> &mut StyleRefinement {
705 &mut self.style
706 }
707}
708
709impl RenderOnce for InputGroupText {
710 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
711 h_flex()
712 .gap_2()
713 .text_sm()
714 .text_color(cx.theme().muted_foreground)
715 .refine_style(&self.style)
716 .children(self.children.into_iter().map(addon_child))
717 }
718}
719
720fn addon_child(mut child: AnyElement) -> AnyElement {
721 if button_element::unwrapped_element(&mut child)
722 .downcast_mut::<ViewElement<Icon>>()
723 .is_some()
724 {
725 div()
728 .flex_none()
729 .text_base()
730 .child(child)
731 .into_any_element()
732 } else {
733 child
734 }
735}
736
737#[cfg(test)]
738mod tests;
739
740mod button_element;