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. A color cannot
189        // overshoot its target, so the theme curve is clamped.
190        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                // Custom addon content must not bypass the group's disabled policy.
228                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                    // Native buttons prevent the default mouse-down focus action.
244                    // Respect that before focusing the editor from an addon or inset.
245                    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
269/// The control rendered without its own frame: the group draws the border,
270/// background and ring. An inline addon takes over part of the control's
271/// horizontal inset, as in shadcn; the caller's own style still wins.
272fn 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        // Validation remains visible when editing is disabled. Focus alone never
311        // reactivates a disabled control, and never replaces its validation color.
312        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/// The logical side of an addon relative to the text control.
335#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
336pub enum InputGroupAddonAlignment {
337    #[default]
338    InlineStart,
339    InlineEnd,
340    BlockStart,
341    BlockEnd,
342}
343
344/// Text, icons, buttons, or custom content on one side of an input group.
345///
346/// Children retain their insertion order. Direct `InputGroupButton` children
347/// inherit the group's disabled state. Custom children retain their own semantics.
348#[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        // An inline addon keeps the same clearance from the frame's edge that
393        // a compact button has from its top and bottom; block addons share the
394        // control's horizontal inset so a leading icon or a trailing button
395        // lines up with the text.
396        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
452/// The single-line control of a group: an [`Input`] placed in the frame, with
453/// every `Input` capability.
454pub type InputGroupInput = Input;
455
456/// The multi-line control of a group: a [`Textarea`] placed in the frame,
457/// with every `Textarea` capability.
458pub type InputGroupTextarea = Textarea;
459
460/// A single-line input or textarea accepted by [`InputGroup::input`].
461pub 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
475/// A [`Button`] with compact input-group presentation and disabled inheritance.
476///
477/// Ghost and extra-small by default, as in shadcn; `xsmall` and `small` are
478/// the two compact sizes, and a button with only an icon is square at either.
479pub 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/// Muted helper text, optionally combined with icons, inside an input group.
685#[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        // An icon without an explicit size follows the addon's one-rem
726        // default; an explicit size stays with the icon.
727        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;