Skip to main content

gpui_component/input/
group.rs

1//! A shared frame around one text control and its addons.
2//!
3//! The parts follow shadcn's input group: [`InputGroup`] is the frame,
4//! [`InputGroupInput`] and [`InputGroupTextarea`] are the ordinary [`Input`]
5//! and [`Textarea`] placed in it, [`InputGroupAddon`] holds text, icons and
6//! buttons on one of its four sides, [`InputGroupButton`] is a [`Button`] with
7//! compact input-group presentation and [`InputGroupText`] is muted text. The
8//! caller keeps the `InputState` or `TextareaState`; the group owns only
9//! composition and the frame.
10
11use 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/// A shared frame around one text control and any number of explicitly aligned addons.
31///
32/// The caller retains the `InputState` or `TextareaState`. The group owns only
33/// composition and presentation; the control keeps every `Input` capability
34/// and renders without its own frame. `input` replaces the slot.
35#[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    /// Set the single-line input or textarea, replacing the previous control.
66    pub fn input(mut self, input: impl Into<InputGroupControl>) -> Self {
67        self.control = Some(input.into().0);
68        self
69    }
70
71    /// Append an addon. Addons on the same side retain their insertion order.
72    pub fn addon(mut self, addon: InputGroupAddon) -> Self {
73        self.addons.push(addon);
74        self
75    }
76
77    /// Prevent editing and interaction throughout this group.
78    pub fn disabled(mut self, disabled: bool) -> Self {
79        self.disabled = disabled;
80        self
81    }
82
83    /// Prevent editing while preserving selection, copying, and addon actions.
84    pub fn readonly(mut self, readonly: bool) -> Self {
85        self.readonly = readonly;
86        self
87    }
88
89    /// Display the caller's validation result. This does not reject text edits.
90    pub fn invalid(mut self, invalid: bool) -> Self {
91        self.invalid = invalid;
92        self
93    }
94
95    /// Set a name for the group. Name its text control separately with `aria_label`.
96    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/// What the addons and the control need to know about the group they sit in.
127#[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        // The border and background colors transition; the ring outside them
188        // changes immediately, like the standalone input's.
189        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                // Custom addon content must not bypass the group's disabled policy.
223                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                    // Native buttons prevent the default mouse-down focus action.
239                    // Respect that before focusing the editor from an addon or inset.
240                    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
264/// The control rendered without its own frame: the group draws the border,
265/// background and ring. An inline addon takes over part of the control's
266/// horizontal inset, as in shadcn; the caller's own style still wins.
267fn 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        // Validation remains visible when editing is disabled. Focus alone never
306        // reactivates a disabled control, and never replaces its validation color.
307        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/// The logical side of an addon relative to the text control.
330#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
331pub enum InputGroupAddonAlignment {
332    #[default]
333    InlineStart,
334    InlineEnd,
335    BlockStart,
336    BlockEnd,
337}
338
339/// Text, icons, buttons, or custom content on one side of an input group.
340///
341/// Children retain their insertion order. Direct `InputGroupButton` children
342/// inherit the group's disabled state. Custom children retain their own semantics.
343#[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        // An inline addon keeps the same clearance from the frame's edge that
388        // a compact button has from its top and bottom; block addons share the
389        // control's horizontal inset so a leading icon or a trailing button
390        // lines up with the text.
391        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
447/// The single-line control of a group: an [`Input`] placed in the frame, with
448/// every `Input` capability.
449pub type InputGroupInput = Input;
450
451/// The multi-line control of a group: a [`Textarea`] placed in the frame,
452/// with every `Textarea` capability.
453pub type InputGroupTextarea = Textarea;
454
455/// A single-line input or textarea accepted by [`InputGroup::input`].
456pub 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
470/// A [`Button`] with compact input-group presentation and disabled inheritance.
471///
472/// Ghost and extra-small by default, as in shadcn; `xsmall` and `small` are
473/// the two compact sizes, and a button with only an icon is square at either.
474pub 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/// Muted helper text, optionally combined with icons, inside an input group.
680#[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        // An icon without an explicit size follows the addon's one-rem
721        // default; an explicit size stays with the icon.
722        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;