Skip to main content

iced_shadcn/
input_group.rs

1use iced::advanced::text::Wrapping;
2use iced::border::Border;
3use iced::widget::{column, container, row, text, text_editor, text_input};
4use iced::{Background, Color, Element, Length};
5
6use crate::button::{
7    ButtonProps, ButtonRadius, ButtonSize, ButtonVariant, button_content, icon_button,
8};
9use crate::input::InputSize;
10use crate::textarea::{TextareaProps, TextareaResize, TextareaSize, textarea_apply_action};
11use crate::theme::Theme;
12use crate::tokens::{AccentColor, accent_color, ensure_contrast, is_dark};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
15pub enum InputGroupAddonAlign {
16    #[default]
17    InlineStart,
18    InlineEnd,
19    BlockStart,
20    BlockEnd,
21}
22
23impl InputGroupAddonAlign {
24    fn is_block(self) -> bool {
25        matches!(
26            self,
27            InputGroupAddonAlign::BlockStart | InputGroupAddonAlign::BlockEnd
28        )
29    }
30}
31
32#[derive(Clone, Copy, Debug, Default)]
33pub struct InputGroupProps {
34    pub radius: Option<ButtonRadius>,
35    pub invalid: bool,
36    pub disabled: bool,
37}
38
39impl InputGroupProps {
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    pub fn radius(mut self, radius: ButtonRadius) -> Self {
45        self.radius = Some(radius);
46        self
47    }
48
49    pub fn invalid(mut self, invalid: bool) -> Self {
50        self.invalid = invalid;
51        self
52    }
53
54    pub fn disabled(mut self, disabled: bool) -> Self {
55        self.disabled = disabled;
56        self
57    }
58}
59
60#[derive(Clone, Copy, Debug)]
61pub struct InputGroupAddonProps {
62    pub align: InputGroupAddonAlign,
63}
64
65impl Default for InputGroupAddonProps {
66    fn default() -> Self {
67        Self {
68            align: InputGroupAddonAlign::InlineStart,
69        }
70    }
71}
72
73impl InputGroupAddonProps {
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    pub fn align(mut self, align: InputGroupAddonAlign) -> Self {
79        self.align = align;
80        self
81    }
82}
83
84pub struct InputGroupAddon<'a, Message> {
85    pub content: Element<'a, Message>,
86    pub props: InputGroupAddonProps,
87}
88
89pub enum InputGroupItem<'a, Message> {
90    Control(Element<'a, Message>),
91    Addon(InputGroupAddon<'a, Message>),
92}
93
94pub fn input_group_addon<'a, Message>(
95    content: impl Into<Element<'a, Message>>,
96    props: InputGroupAddonProps,
97) -> InputGroupItem<'a, Message> {
98    InputGroupItem::Addon(InputGroupAddon {
99        content: content.into(),
100        props,
101    })
102}
103
104pub fn input_group_control<'a, Message>(
105    content: impl Into<Element<'a, Message>>,
106) -> InputGroupItem<'a, Message> {
107    InputGroupItem::Control(content.into())
108}
109
110pub fn input_group<'a, Message: Clone + 'a>(
111    items: Vec<InputGroupItem<'a, Message>>,
112    props: InputGroupProps,
113    theme: &Theme,
114) -> Element<'a, Message> {
115    let has_block = items.iter().any(|item| match item {
116        InputGroupItem::Addon(addon) => addon.props.align.is_block(),
117        _ => false,
118    });
119
120    let mut children: Vec<Element<'a, Message>> = Vec::with_capacity(items.len());
121    for item in items {
122        match item {
123            InputGroupItem::Control(content) => children.push(content),
124            InputGroupItem::Addon(addon) => {
125                children.push(render_addon(addon, props.disabled, theme))
126            }
127        }
128    }
129
130    let content: Element<'a, Message> = if has_block {
131        column(children).spacing(0).into()
132    } else {
133        row(children).spacing(0).into()
134    };
135
136    let theme = theme.clone();
137    container(content)
138        .width(Length::Fill)
139        .style(move |_t| input_group_style(&theme, props))
140        .into()
141}
142
143fn render_addon<'a, Message: Clone + 'a>(
144    addon: InputGroupAddon<'a, Message>,
145    disabled: bool,
146    theme: &Theme,
147) -> Element<'a, Message> {
148    let padding = match addon.props.align {
149        InputGroupAddonAlign::InlineStart | InputGroupAddonAlign::InlineEnd => [6.0, 12.0],
150        InputGroupAddonAlign::BlockStart | InputGroupAddonAlign::BlockEnd => [8.0, 12.0],
151    };
152
153    let muted = theme.palette.muted_foreground;
154    let disabled_color = apply_opacity(muted, 0.6);
155    let mut wrapper =
156        container(addon.content)
157            .padding(padding)
158            .style(move |_t| iced::widget::container::Style {
159                text_color: Some(if disabled { disabled_color } else { muted }),
160                ..Default::default()
161            });
162
163    if matches!(
164        addon.props.align,
165        InputGroupAddonAlign::BlockStart | InputGroupAddonAlign::BlockEnd
166    ) {
167        wrapper = wrapper.width(Length::Fill);
168    }
169
170    wrapper.into()
171}
172
173#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
174pub enum InputGroupButtonSize {
175    #[default]
176    Xs,
177    Sm,
178    IconXs,
179    IconSm,
180}
181
182impl InputGroupButtonSize {
183    fn button_size(self) -> ButtonSize {
184        match self {
185            InputGroupButtonSize::Xs | InputGroupButtonSize::IconXs => ButtonSize::Size1,
186            InputGroupButtonSize::Sm | InputGroupButtonSize::IconSm => ButtonSize::Size2,
187        }
188    }
189
190    fn is_icon(self) -> bool {
191        matches!(
192            self,
193            InputGroupButtonSize::IconXs | InputGroupButtonSize::IconSm
194        )
195    }
196}
197
198#[derive(Clone, Copy, Debug)]
199pub struct InputGroupButtonProps {
200    pub variant: ButtonVariant,
201    pub size: InputGroupButtonSize,
202    pub disabled: bool,
203}
204
205impl Default for InputGroupButtonProps {
206    fn default() -> Self {
207        Self {
208            variant: ButtonVariant::Ghost,
209            size: InputGroupButtonSize::Xs,
210            disabled: false,
211        }
212    }
213}
214
215impl InputGroupButtonProps {
216    pub fn new() -> Self {
217        Self::default()
218    }
219
220    pub fn variant(mut self, variant: ButtonVariant) -> Self {
221        self.variant = variant;
222        self
223    }
224
225    pub fn size(mut self, size: InputGroupButtonSize) -> Self {
226        self.size = size;
227        self
228    }
229
230    pub fn disabled(mut self, disabled: bool) -> Self {
231        self.disabled = disabled;
232        self
233    }
234}
235
236pub fn input_group_button<'a, Message: Clone + 'a>(
237    content: impl Into<Element<'a, Message>>,
238    on_press: Option<Message>,
239    props: InputGroupButtonProps,
240    theme: &Theme,
241) -> Element<'a, Message> {
242    let button_props = ButtonProps::new()
243        .variant(props.variant)
244        .size(props.size.button_size())
245        .disabled(props.disabled);
246
247    if props.size.is_icon() {
248        icon_button(content, on_press, button_props, theme).into()
249    } else {
250        button_content(content, on_press, button_props, theme).into()
251    }
252}
253
254pub fn input_group_text<'a, Message: Clone + 'a>(
255    value: impl Into<String>,
256    theme: &'a Theme,
257) -> Element<'a, Message> {
258    text(value.into())
259        .size(12.0)
260        .style(move |_t| iced::widget::text::Style {
261            color: Some(theme.palette.muted_foreground),
262        })
263        .into()
264}
265
266#[derive(Clone, Copy, Debug)]
267pub struct InputGroupInputProps {
268    pub size: InputSize,
269    pub disabled: bool,
270    pub read_only: bool,
271}
272
273impl Default for InputGroupInputProps {
274    fn default() -> Self {
275        Self {
276            size: InputSize::Size2,
277            disabled: false,
278            read_only: false,
279        }
280    }
281}
282
283impl InputGroupInputProps {
284    pub fn new() -> Self {
285        Self::default()
286    }
287
288    pub fn size(mut self, size: InputSize) -> Self {
289        self.size = size;
290        self
291    }
292
293    pub fn disabled(mut self, disabled: bool) -> Self {
294        self.disabled = disabled;
295        self
296    }
297
298    pub fn read_only(mut self, read_only: bool) -> Self {
299        self.read_only = read_only;
300        self
301    }
302}
303
304pub fn input_group_input<'a, Message: Clone + 'a, F>(
305    value: &'a str,
306    placeholder: &'a str,
307    on_input: Option<F>,
308    props: InputGroupInputProps,
309    theme: &Theme,
310) -> InputGroupItem<'a, Message>
311where
312    F: Fn(String) -> Message + 'a,
313{
314    let theme = theme.clone();
315    let mut widget = text_input::TextInput::new(placeholder, value)
316        .padding(input_padding(props.size))
317        .size(input_text_size(props.size))
318        .style(move |_t, status| input_group_input_style(&theme, props, status));
319
320    if let Some(on_input) = on_input {
321        if props.disabled {
322            widget = widget.on_input_maybe(None::<fn(String) -> Message>);
323        } else {
324            widget = widget.on_input(on_input);
325        }
326    } else {
327        widget = widget.on_input_maybe(None::<fn(String) -> Message>);
328    }
329
330    InputGroupItem::Control(widget.into())
331}
332
333#[derive(Clone, Copy, Debug)]
334pub struct InputGroupTextareaProps {
335    pub size: TextareaSize,
336    pub disabled: bool,
337    pub text_color: Option<iced::Color>,
338    pub placeholder_color: Option<iced::Color>,
339    pub read_only: bool,
340    pub max_len: Option<usize>,
341    pub rows: Option<usize>,
342    pub resize: TextareaResize,
343    pub wrapping: Wrapping,
344}
345
346impl Default for InputGroupTextareaProps {
347    fn default() -> Self {
348        Self {
349            size: TextareaSize::Size2,
350            disabled: false,
351            text_color: None,
352            placeholder_color: None,
353            read_only: false,
354            max_len: None,
355            rows: None,
356            resize: TextareaResize::None,
357            wrapping: Wrapping::WordOrGlyph,
358        }
359    }
360}
361
362impl InputGroupTextareaProps {
363    pub fn new() -> Self {
364        Self::default()
365    }
366
367    pub fn size(mut self, size: TextareaSize) -> Self {
368        self.size = size;
369        self
370    }
371
372    pub fn disabled(mut self, disabled: bool) -> Self {
373        self.disabled = disabled;
374        self
375    }
376
377    pub fn text_color(mut self, color: iced::Color) -> Self {
378        self.text_color = Some(color);
379        self
380    }
381
382    pub fn placeholder_color(mut self, color: iced::Color) -> Self {
383        self.placeholder_color = Some(color);
384        self
385    }
386
387    pub fn read_only(mut self, read_only: bool) -> Self {
388        self.read_only = read_only;
389        self
390    }
391
392    pub fn max_len(mut self, max_len: usize) -> Self {
393        self.max_len = Some(max_len);
394        self
395    }
396
397    pub fn rows(mut self, rows: usize) -> Self {
398        self.rows = Some(rows);
399        self
400    }
401
402    pub fn resize(mut self, resize: TextareaResize) -> Self {
403        self.resize = resize;
404        self
405    }
406
407    pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
408        self.wrapping = wrapping;
409        self
410    }
411}
412
413pub fn input_group_textarea<'a, Message: Clone + 'a, F>(
414    content: &'a text_editor::Content,
415    placeholder: &'a str,
416    on_action: Option<F>,
417    props: InputGroupTextareaProps,
418    theme: &Theme,
419) -> InputGroupItem<'a, Message>
420where
421    F: Fn(text_editor::Action) -> Message + 'a,
422{
423    let theme = theme.clone();
424    let padding = textarea_padding(props.size);
425    let text_size = textarea_text_size(props.size);
426    let min_height = textarea_min_height(props);
427    let mut widget = text_editor::TextEditor::new(content)
428        .placeholder(placeholder)
429        .padding(padding)
430        .size(text_size)
431        .min_height(min_height)
432        .wrapping(props.wrapping)
433        .style(move |_t, status| input_group_textarea_style(&theme, props, status));
434
435    if props.resize == TextareaResize::None {
436        widget = widget.height(Length::Fixed(min_height));
437    }
438
439    if !props.disabled
440        && let Some(on_action) = on_action
441    {
442        widget = widget.on_action(on_action);
443    }
444
445    InputGroupItem::Control(widget.into())
446}
447
448pub fn input_group_textarea_apply_action(
449    content: &mut text_editor::Content,
450    action: text_editor::Action,
451    props: InputGroupTextareaProps,
452) -> bool {
453    let mut textarea_props = TextareaProps::new()
454        .size(props.size)
455        .resize(props.resize)
456        .disabled(props.disabled)
457        .read_only(props.read_only);
458
459    if let Some(max_len) = props.max_len {
460        textarea_props = textarea_props.max_len(max_len);
461    }
462
463    textarea_apply_action(content, action, textarea_props)
464}
465
466fn input_padding(size: InputSize) -> [f32; 2] {
467    match size {
468        InputSize::Size1 => [6.0, 10.0],
469        InputSize::Size2 => [8.0, 12.0],
470        InputSize::Size3 => [10.0, 14.0],
471    }
472}
473
474fn input_text_size(size: InputSize) -> u32 {
475    match size {
476        InputSize::Size1 | InputSize::Size2 => 14,
477        InputSize::Size3 => 16,
478    }
479}
480
481fn textarea_padding(size: TextareaSize) -> [f32; 2] {
482    match size {
483        TextareaSize::Size1 => [6.0, 10.0],
484        TextareaSize::Size2 => [8.0, 12.0],
485        TextareaSize::Size3 => [10.0, 14.0],
486    }
487}
488
489fn textarea_text_size(size: TextareaSize) -> u32 {
490    match size {
491        TextareaSize::Size1 | TextareaSize::Size2 => 14,
492        TextareaSize::Size3 => 16,
493    }
494}
495
496fn textarea_min_height(props: InputGroupTextareaProps) -> f32 {
497    if let Some(rows) = props.rows {
498        let rows = rows.max(1) as f32;
499        let text_size = textarea_text_size(props.size) as f32;
500        let line_height = text_size * 1.4;
501        let padding = textarea_padding(props.size);
502        return line_height * rows + padding[0] * 2.0;
503    }
504
505    match props.size {
506        TextareaSize::Size1 => 64.0,
507        TextareaSize::Size2 => 96.0,
508        TextareaSize::Size3 => 128.0,
509    }
510}
511
512fn input_group_radius(theme: &Theme, props: InputGroupProps) -> f32 {
513    match props.radius {
514        Some(ButtonRadius::None) => 0.0,
515        Some(ButtonRadius::Small) => theme.radius.sm,
516        Some(ButtonRadius::Medium) => theme.radius.md,
517        Some(ButtonRadius::Large) => theme.radius.lg,
518        Some(ButtonRadius::Full) => 9999.0,
519        None => theme.radius.sm,
520    }
521}
522
523fn input_group_style(theme: &Theme, props: InputGroupProps) -> iced::widget::container::Style {
524    let palette = theme.palette;
525    let radius = input_group_radius(theme, props);
526
527    let background = if props.disabled {
528        palette.muted
529    } else if is_dark(&palette) {
530        palette.input
531    } else {
532        palette.background
533    };
534
535    let border_color = if props.invalid {
536        palette.destructive
537    } else {
538        palette.border
539    };
540
541    let text_color = if props.disabled {
542        palette.muted_foreground
543    } else {
544        palette.foreground
545    };
546
547    iced::widget::container::Style {
548        background: Some(Background::Color(background)),
549        text_color: Some(text_color),
550        border: Border {
551            radius: radius.into(),
552            width: 1.0,
553            color: border_color,
554        },
555        ..Default::default()
556    }
557}
558
559fn input_group_input_style(
560    theme: &Theme,
561    props: InputGroupInputProps,
562    status: text_input::Status,
563) -> text_input::Style {
564    let palette = theme.palette;
565    let accent = accent_color(&palette, AccentColor::Gray);
566
567    let mut value = palette.foreground;
568    let mut placeholder = palette.muted_foreground;
569
570    if props.disabled {
571        value = palette.muted_foreground;
572        placeholder = palette.muted_foreground;
573    }
574
575    let mut border = Border {
576        radius: 0.0.into(),
577        width: 0.0,
578        color: Color::TRANSPARENT,
579    };
580
581    if matches!(status, text_input::Status::Focused { .. }) {
582        border.color = palette.ring;
583    }
584
585    text_input::Style {
586        background: Background::Color(Color::TRANSPARENT),
587        border,
588        icon: palette.muted_foreground,
589        placeholder,
590        value,
591        selection: accent,
592    }
593}
594
595fn input_group_textarea_style(
596    theme: &Theme,
597    props: InputGroupTextareaProps,
598    _status: text_editor::Status,
599) -> text_editor::Style {
600    let palette = theme.palette;
601    let accent = accent_color(&palette, AccentColor::Gray);
602
603    let mut value = if props.disabled {
604        palette.muted_foreground
605    } else {
606        palette.foreground
607    };
608    let mut placeholder = palette.muted_foreground;
609    let mut selection = accent;
610    let value_overridden = props.text_color.is_some();
611    let placeholder_overridden = props.placeholder_color.is_some();
612
613    if !props.disabled {
614        if let Some(color) = props.text_color {
615            value = color;
616        }
617        if let Some(color) = props.placeholder_color {
618            placeholder = color;
619        }
620
621        let background = Background::Color(Color::TRANSPARENT);
622        let fallback_bg = palette.background;
623        if !value_overridden {
624            value = ensure_contrast(background, fallback_bg, value);
625        }
626        if !placeholder_overridden {
627            placeholder = ensure_contrast(background, fallback_bg, placeholder);
628        }
629    }
630
631    if props.disabled {
632        selection = palette.muted;
633    }
634
635    if props.read_only && !props.disabled {
636        value = palette.muted_foreground;
637        placeholder = palette.muted_foreground;
638        selection = palette.muted;
639    }
640
641    text_editor::Style {
642        background: Background::Color(Color::TRANSPARENT),
643        border: Border {
644            radius: 0.0.into(),
645            width: 0.0,
646            color: Color::TRANSPARENT,
647        },
648        placeholder,
649        value,
650        selection,
651    }
652}
653
654fn apply_opacity(color: Color, opacity: f32) -> Color {
655    Color {
656        a: color.a * opacity,
657        ..color
658    }
659}