1use iced::border::Border;
2use iced::widget::{column, container, row, rule, text};
3use iced::{Alignment, Background, Element, Length};
4use std::hash::Hash;
5
6use crate::button::{ButtonProps, ButtonSize, ButtonVariant, button_content};
7use crate::input::{InputProps, InputSize, InputVariant, input};
8use crate::popover::{PopoverProps, PopoverSize, popover};
9use crate::theme::Theme;
10use crate::tokens::{accent_soft, accent_text};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum ComboboxSize {
14 Size1,
15 #[default]
16 Size2,
17 Size3,
18}
19
20impl From<ComboboxSize> for ButtonSize {
21 fn from(size: ComboboxSize) -> Self {
22 match size {
23 ComboboxSize::Size1 => ButtonSize::Size1,
24 ComboboxSize::Size2 => ButtonSize::Size2,
25 ComboboxSize::Size3 => ButtonSize::Size3,
26 }
27 }
28}
29
30impl From<ComboboxSize> for InputSize {
31 fn from(size: ComboboxSize) -> Self {
32 match size {
33 ComboboxSize::Size1 => InputSize::Size1,
34 ComboboxSize::Size2 => InputSize::Size2,
35 ComboboxSize::Size3 => InputSize::Size3,
36 }
37 }
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41pub enum ButtonJustify {
42 Start,
43 Center,
44 #[default]
45 Between,
46}
47
48#[derive(Clone, Debug)]
49pub enum SelectItem {
50 Option {
51 value: String,
52 label: String,
53 disabled: bool,
54 text_value: Option<String>,
55 },
56 Group {
57 label: String,
58 items: Vec<SelectItem>,
59 },
60 Separator,
61 Label(String),
62}
63
64impl SelectItem {
65 pub fn option(value: impl Into<String>, label: impl Into<String>) -> Self {
66 Self::Option {
67 value: value.into(),
68 label: label.into(),
69 disabled: false,
70 text_value: None,
71 }
72 }
73
74 pub fn option_disabled(value: impl Into<String>, label: impl Into<String>) -> Self {
75 Self::Option {
76 value: value.into(),
77 label: label.into(),
78 disabled: true,
79 text_value: None,
80 }
81 }
82
83 pub fn option_with_text_value(
84 value: impl Into<String>,
85 label: impl Into<String>,
86 text_value: impl Into<String>,
87 ) -> Self {
88 Self::Option {
89 value: value.into(),
90 label: label.into(),
91 disabled: false,
92 text_value: Some(text_value.into()),
93 }
94 }
95
96 pub fn option_disabled_with_text_value(
97 value: impl Into<String>,
98 label: impl Into<String>,
99 text_value: impl Into<String>,
100 ) -> Self {
101 Self::Option {
102 value: value.into(),
103 label: label.into(),
104 disabled: true,
105 text_value: Some(text_value.into()),
106 }
107 }
108
109 pub fn group(label: impl Into<String>, items: Vec<SelectItem>) -> Self {
110 Self::Group {
111 label: label.into(),
112 items,
113 }
114 }
115
116 pub fn separator() -> Self {
117 Self::Separator
118 }
119
120 pub fn label(text: impl Into<String>) -> Self {
121 Self::Label(text.into())
122 }
123}
124
125pub struct ComboboxProps<'a, Id> {
126 pub id_source: Id,
127 pub value: &'a Option<String>,
128 pub search_value: &'a str,
129 pub items: &'a [SelectItem],
130 pub placeholder: &'a str,
131 pub search_placeholder: &'a str,
132 pub empty_text: &'a str,
133 pub size: ComboboxSize,
134 pub variant: InputVariant,
135 pub trigger_variant: ButtonVariant,
136 pub trigger_justify: ButtonJustify,
137 pub disabled: bool,
138 pub width: Option<f32>,
139}
140
141impl<'a, Id: Hash> ComboboxProps<'a, Id> {
142 pub fn new(
143 id_source: Id,
144 value: &'a Option<String>,
145 items: &'a [SelectItem],
146 search_value: &'a str,
147 ) -> Self {
148 Self {
149 id_source,
150 value,
151 search_value,
152 items,
153 placeholder: "Select option...",
154 search_placeholder: "Search...",
155 empty_text: "No option found.",
156 size: ComboboxSize::Size2,
157 variant: InputVariant::Surface,
158 trigger_variant: ButtonVariant::Outline,
159 trigger_justify: ButtonJustify::Between,
160 disabled: false,
161 width: None,
162 }
163 }
164
165 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
166 self.placeholder = placeholder;
167 self
168 }
169
170 pub fn search_placeholder(mut self, placeholder: &'a str) -> Self {
171 self.search_placeholder = placeholder;
172 self
173 }
174
175 pub fn empty_text(mut self, empty_text: &'a str) -> Self {
176 self.empty_text = empty_text;
177 self
178 }
179
180 pub fn size(mut self, size: ComboboxSize) -> Self {
181 self.size = size;
182 self
183 }
184
185 pub fn variant(mut self, variant: InputVariant) -> Self {
186 self.variant = variant;
187 self
188 }
189
190 pub fn trigger_variant(mut self, variant: ButtonVariant) -> Self {
191 self.trigger_variant = variant;
192 self
193 }
194
195 pub fn trigger_justify(mut self, justify: ButtonJustify) -> Self {
196 self.trigger_justify = justify;
197 self
198 }
199
200 pub fn disabled(mut self, disabled: bool) -> Self {
201 self.disabled = disabled;
202 self
203 }
204
205 pub fn width(mut self, width: f32) -> Self {
206 self.width = Some(width);
207 self
208 }
209}
210
211fn get_selected_label(items: &[SelectItem], value: &Option<String>) -> Option<String> {
212 if let Some(val) = value {
213 for item in items {
214 match item {
215 SelectItem::Option { value, label, .. } => {
216 if value == val {
217 return Some(label.clone());
218 }
219 }
220 SelectItem::Group { items, .. } => {
221 if let Some(label) = get_selected_label(items, value) {
222 return Some(label);
223 }
224 }
225 _ => {}
226 }
227 }
228 }
229 None
230}
231
232fn filter_items(items: &[SelectItem], search: &str) -> Vec<SelectItem> {
233 if search.trim().is_empty() {
234 return items.to_vec();
235 }
236
237 let search_lower = search.to_lowercase();
238 let mut filtered = Vec::new();
239
240 for item in items {
241 match item {
242 SelectItem::Option {
243 value,
244 label,
245 text_value,
246 disabled,
247 } => {
248 let searchable = text_value.as_deref().unwrap_or(label);
249 if searchable.to_lowercase().contains(&search_lower)
250 || value.to_lowercase().contains(&search_lower)
251 {
252 filtered.push(SelectItem::Option {
253 value: value.clone(),
254 label: label.clone(),
255 disabled: *disabled,
256 text_value: text_value.clone(),
257 });
258 }
259 }
260 SelectItem::Group { label, items } => {
261 let filtered_items = filter_items(items, search);
262 if !filtered_items.is_empty() {
263 filtered.push(SelectItem::Group {
264 label: label.clone(),
265 items: filtered_items,
266 });
267 }
268 }
269 SelectItem::Separator => filtered.push(SelectItem::Separator),
270 SelectItem::Label(text) => filtered.push(SelectItem::Label(text.clone())),
271 }
272 }
273
274 filtered
275}
276
277pub fn combobox<'a, Message: Clone + 'a, Id: Hash, F, G>(
278 props: ComboboxProps<'a, Id>,
279 on_value_change: Option<F>,
280 on_search_change: Option<G>,
281 theme: &'a Theme,
282) -> Element<'a, Message>
283where
284 F: Fn(Option<String>) -> Message + 'a,
285 G: Fn(String) -> Message + 'a,
286{
287 let selected_label = get_selected_label(props.items, props.value)
288 .unwrap_or_else(|| props.placeholder.to_string());
289 let size = ButtonSize::from(props.size);
290
291 let label = text(selected_label).size(13);
292 let chevron = text("▾").size(12);
293
294 let trigger_content: Element<'a, Message> = match props.trigger_justify {
295 ButtonJustify::Between => row![label, iced::widget::space().width(Length::Fill), chevron]
296 .align_y(Alignment::Center)
297 .width(Length::Fill)
298 .into(),
299 ButtonJustify::Center => {
300 container(row![label, chevron].spacing(6).align_y(Alignment::Center))
301 .width(Length::Fill)
302 .center_x(Length::Fill)
303 .into()
304 }
305 ButtonJustify::Start => row![label, chevron]
306 .spacing(6)
307 .align_y(Alignment::Center)
308 .into(),
309 };
310
311 let mut trigger = button_content(
312 trigger_content,
313 None::<Message>,
314 ButtonProps::new()
315 .variant(props.trigger_variant)
316 .size(size)
317 .disabled(props.disabled),
318 theme,
319 );
320
321 if let Some(width) = props.width {
322 trigger = trigger.width(Length::Fixed(width));
323 }
324
325 let items = filter_items(props.items, props.search_value);
326 let on_value_change = on_value_change.as_ref();
327 let search_enabled = on_search_change.is_some();
328 let on_search_change = on_search_change.map(|f| move |value| f(value));
329
330 let mut list: Vec<Element<'a, Message>> = Vec::new();
331 for item in items {
332 match item {
333 SelectItem::Option {
334 value,
335 label,
336 disabled,
337 ..
338 } => {
339 let enabled = !disabled && on_value_change.is_some();
340 let on_press = on_value_change
341 .map(|f| f(Some(value.clone())))
342 .filter(|_| enabled);
343 let element = button_content(
344 text(label),
345 on_press,
346 ButtonProps::new()
347 .variant(ButtonVariant::Ghost)
348 .size(ButtonSize::Size1)
349 .disabled(!enabled),
350 theme,
351 )
352 .width(Length::Fill)
353 .into();
354 list.push(element);
355 }
356 SelectItem::Group { label, items } => {
357 list.push(
358 text(label)
359 .size(11)
360 .style(move |_t| iced::widget::text::Style {
361 color: Some(theme.palette.muted_foreground),
362 })
363 .into(),
364 );
365 for child in items {
366 if let SelectItem::Option {
367 value,
368 label,
369 disabled,
370 ..
371 } = child
372 {
373 let enabled = !disabled && on_value_change.is_some();
374 let on_press = on_value_change
375 .map(|f| f(Some(value.clone())))
376 .filter(|_| enabled);
377 let element = button_content(
378 text(label),
379 on_press,
380 ButtonProps::new()
381 .variant(ButtonVariant::Ghost)
382 .size(ButtonSize::Size1)
383 .disabled(!enabled),
384 theme,
385 )
386 .width(Length::Fill)
387 .into();
388 list.push(element);
389 }
390 }
391 }
392 SelectItem::Separator => {
393 list.push(rule::horizontal(1).into());
394 }
395 SelectItem::Label(text_value) => {
396 list.push(text(text_value).size(11).into());
397 }
398 }
399 }
400
401 if list.is_empty() {
402 list.push(
403 text(props.empty_text)
404 .size(12)
405 .style(move |_t| iced::widget::text::Style {
406 color: Some(theme.palette.muted_foreground),
407 })
408 .into(),
409 );
410 }
411
412 let search_disabled = props.disabled || !search_enabled;
413 let search_input = input(
414 props.search_value,
415 props.search_placeholder,
416 on_search_change,
417 InputProps::new()
418 .size(InputSize::from(props.size))
419 .variant(props.variant)
420 .disabled(search_disabled),
421 theme,
422 )
423 .width(Length::Fill);
424
425 let content: Element<'a, Message> =
426 container(column![search_input, column(list).spacing(4)].spacing(8))
427 .padding(8)
428 .width(Length::Shrink)
429 .style(move |_t| iced::widget::container::Style {
430 background: Some(Background::Color(theme.palette.popover)),
431 text_color: Some(theme.palette.popover_foreground),
432 border: Border {
433 radius: theme.radius.md.into(),
434 width: 1.0,
435 color: theme.palette.border,
436 },
437 ..Default::default()
438 })
439 .into();
440
441 let trigger: Element<'a, Message> = container(trigger)
442 .width(props.width.map(Length::Fixed).unwrap_or(Length::Shrink))
443 .style(move |_t| combobox_trigger_style(theme, props.variant))
444 .into();
445
446 popover(
447 trigger,
448 content,
449 PopoverProps::new().size(PopoverSize::Size2).offset(6.0),
450 theme,
451 )
452 .into()
453}
454
455fn combobox_trigger_style(theme: &Theme, variant: InputVariant) -> iced::widget::container::Style {
456 let palette = theme.palette;
457 let (background, border_color, text_color, border_width) = match variant {
458 InputVariant::Surface => (palette.background, palette.border, palette.foreground, 1.0),
459 InputVariant::Classic => (palette.background, palette.border, palette.foreground, 1.0),
460 InputVariant::Soft => (
461 accent_soft(&palette, crate::tokens::AccentColor::Gray),
462 palette.border,
463 accent_text(&palette, crate::tokens::AccentColor::Gray),
464 1.0,
465 ),
466 InputVariant::Ghost => (
467 iced::Color::TRANSPARENT,
468 iced::Color::TRANSPARENT,
469 palette.foreground,
470 0.0,
471 ),
472 };
473
474 iced::widget::container::Style {
475 background: Some(Background::Color(background)),
476 text_color: Some(text_color),
477 border: Border {
478 radius: theme.radius.sm.into(),
479 width: border_width,
480 color: border_color,
481 },
482 ..Default::default()
483 }
484}