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}