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