1use std::borrow::Cow;
10
11use iced::advanced::layout::{self, Layout};
12use iced::advanced::widget::{Operation, Tree, Widget, tree};
13use iced::advanced::{Clipboard, Shell, overlay, renderer};
14use iced::keyboard::key::Named;
15use iced::widget::pick_list::{self, Status};
16use iced::widget::{self, Column, row};
17use iced::{
18 Alignment, Background, Border, Color, Element, Event, Length, Rectangle, Renderer, Size, Theme,
19 Vector, mouse, touch,
20};
21
22use crate::data::ellipsis::ellipsis;
23use crate::icon::{opacity, themed};
24use crate::keys::{self, Chord, Keymap};
25use crate::overlay::anchored::{self, Align, Behaviour, Placement, Side};
26use crate::overlay::menu::{self, Parts, ROW_PADDING, RowStatus, Trailing};
27use crate::theme::{Tokens, fade, mix, radius, space};
28
29pub const HEIGHT: f32 = 36.0;
31
32const CHEVRON_SIZE: f32 = 16.0;
33const LINE_HEIGHT: f32 = 20.0;
34
35pub struct Select<'a, T: Clone, Message> {
39 options: Cow<'a, [T]>,
40 selected: Option<T>,
41 placeholder: Option<String>,
42 width: Length,
43 on_select: Option<Box<dyn Fn(T) -> Message + 'a>>,
44}
45
46impl<T: Clone + std::fmt::Debug, Message> std::fmt::Debug for Select<'_, T, Message> {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("Select")
49 .field("options", &self.options)
50 .field("selected", &self.selected)
51 .field("placeholder", &self.placeholder)
52 .field("enabled", &self.on_select.is_some())
53 .finish()
54 }
55}
56
57pub fn select<'a, T, Message>(
60 options: impl Into<Cow<'a, [T]>>,
61 selected: Option<T>,
62) -> Select<'a, T, Message>
63where
64 T: ToString + PartialEq + Clone,
65{
66 Select {
67 options: options.into(),
68 selected,
69 placeholder: None,
70 width: Length::Fixed(200.0),
71 on_select: None,
72 }
73}
74
75impl<'a, T: Clone, Message> Select<'a, T, Message> {
76 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
78 self.placeholder = Some(placeholder.into());
79 self
80 }
81
82 pub fn width(mut self, width: impl Into<Length>) -> Self {
83 self.width = width.into();
84 self
85 }
86
87 pub fn on_select(mut self, on_select: impl Fn(T) -> Message + 'a) -> Self {
89 self.on_select = Some(Box::new(on_select));
90 self
91 }
92
93 pub fn on_select_maybe(mut self, on_select: Option<impl Fn(T) -> Message + 'a>) -> Self {
96 self.on_select = on_select.map(|f| Box::new(f) as Box<dyn Fn(T) -> Message + 'a>);
97 self
98 }
99
100 pub fn is_enabled(&self) -> bool {
101 self.on_select.is_some()
102 }
103}
104
105impl<'a, T, Message> From<Select<'a, T, Message>> for Element<'a, Message>
106where
107 T: ToString + PartialEq + Clone + 'a,
108 Message: Clone + 'a,
109{
110 fn from(select: Select<'a, T, Message>) -> Self {
111 let Select {
112 options,
113 selected,
114 placeholder,
115 width,
116 on_select,
117 } = select;
118 let enabled = on_select.is_some();
119 let label = selected.as_ref().map(ToString::to_string);
120 let has_value = label.is_some();
121
122 let text_colour = move |theme: &Theme| {
123 let style = style(&Tokens::of(theme), Status::Active, enabled);
124 if has_value {
125 style.text_color
126 } else {
127 style.placeholder_color
128 }
129 };
130 let chevron = themed(
131 crate::lucide!(ChevronDown),
132 CHEVRON_SIZE,
133 opacity(enabled),
134 move |theme| style(&Tokens::of(theme), Status::Active, enabled).handle_color,
135 );
136 let field = row![
138 ellipsis(label.or(placeholder).unwrap_or_default()).colour(text_colour),
139 chevron,
140 ]
141 .spacing(space::SM)
142 .align_y(Alignment::Center)
143 .padding([(HEIGHT - LINE_HEIGHT) / 2.0, space::MD])
144 .width(width)
145 .height(HEIGHT);
146
147 let rows = options.iter().map(|option| {
148 let is_selected = selected.as_ref() == Some(option);
149 option_row(option, is_selected, on_select.as_deref())
150 });
151 let list = menu::surface(Column::with_children(rows).width(Length::Fill))
152 .width(Length::Fill)
153 .into();
154
155 Element::new(Picker {
156 field: field.into(),
157 list,
158 enabled,
159 behaviour: Behaviour {
160 placement: Placement::new(Side::Bottom, Align::Start),
161 match_width: true,
162 closes_itself: true,
163 ..Behaviour::default()
164 },
165 })
166 }
167}
168
169fn option_row<'a, T: ToString + Clone, Message: Clone + 'a>(
171 option: &T,
172 selected: bool,
173 on_select: Option<&(dyn Fn(T) -> Message + 'a)>,
174) -> Element<'a, Message> {
175 let label = option.to_string();
176 let trailing = if selected {
177 Trailing::Check
178 } else {
179 Trailing::None
180 };
181 let parts = Parts {
182 trailing,
183 ..Parts::label(&label)
184 };
185 let row = widget::button(menu::content(parts, RowStatus::Idle, false))
186 .padding(ROW_PADDING)
187 .width(Length::Fill)
188 .on_press_maybe(on_select.map(|on_select| on_select(option.clone())))
189 .style(|theme, status| option_style(&Tokens::of(theme), status));
190 menu::inset(row)
191}
192
193pub fn option_style(tokens: &Tokens, status: widget::button::Status) -> widget::button::Style {
195 use widget::button::Status as Button;
196 let status = match status {
197 Button::Hovered | Button::Pressed => RowStatus::Highlighted,
198 Button::Active => RowStatus::Idle,
199 Button::Disabled => RowStatus::Disabled,
200 };
201 let row = menu::row_style(tokens, status, false);
202 widget::button::Style {
203 background: row.background.map(Background::Color),
204 text_color: row.text,
205 border: Border {
206 radius: radius::SM.into(),
207 ..Border::default()
208 },
209 ..widget::button::Style::default()
210 }
211}
212
213struct Picker<'a, Message> {
215 field: Element<'a, Message>,
216 list: Element<'a, Message>,
217 enabled: bool,
218 behaviour: Behaviour<'a, Message>,
219}
220
221#[derive(Debug, Default)]
222struct PickerState {
223 panel: anchored::State,
224 hovered: bool,
225}
226
227impl<Message: Clone> Widget<Message, Theme, Renderer> for Picker<'_, Message> {
228 fn tag(&self) -> tree::Tag {
229 tree::Tag::of::<PickerState>()
230 }
231
232 fn state(&self) -> tree::State {
233 tree::State::new(PickerState::default())
234 }
235
236 fn children(&self) -> Vec<Tree> {
237 vec![Tree::new(&self.field), Tree::new(&self.list)]
238 }
239
240 fn diff(&self, tree: &mut Tree) {
241 tree.diff_children(&[&self.field, &self.list]);
242 }
243
244 fn size(&self) -> Size<Length> {
245 self.field.as_widget().size()
246 }
247
248 fn layout(
249 &mut self,
250 tree: &mut Tree,
251 renderer: &Renderer,
252 limits: &layout::Limits,
253 ) -> layout::Node {
254 self.field
255 .as_widget_mut()
256 .layout(&mut tree.children[0], renderer, limits)
257 }
258
259 fn draw(
260 &self,
261 tree: &Tree,
262 renderer: &mut Renderer,
263 theme: &Theme,
264 style: &renderer::Style,
265 layout: Layout<'_>,
266 cursor: mouse::Cursor,
267 viewport: &Rectangle,
268 ) {
269 let state = tree.state.downcast_ref::<PickerState>();
270 let bounds = layout.bounds();
271 let hovered = self.enabled && cursor.is_over(bounds);
272 let status = field_status(state.panel.open, hovered);
273 let field = self::style(&Tokens::of(theme), status, self.enabled);
274 <Renderer as renderer::Renderer>::fill_quad(
275 renderer,
276 renderer::Quad {
277 bounds,
278 border: field.border,
279 ..renderer::Quad::default()
280 },
281 field.background,
282 );
283 self.field.as_widget().draw(
284 &tree.children[0],
285 renderer,
286 theme,
287 style,
288 layout,
289 cursor,
290 viewport,
291 );
292 }
293
294 fn operate(
295 &mut self,
296 tree: &mut Tree,
297 layout: Layout<'_>,
298 renderer: &Renderer,
299 operation: &mut dyn Operation,
300 ) {
301 self.field
302 .as_widget_mut()
303 .operate(&mut tree.children[0], layout, renderer, operation);
304 }
305
306 fn update(
307 &mut self,
308 tree: &mut Tree,
309 event: &Event,
310 layout: Layout<'_>,
311 cursor: mouse::Cursor,
312 _renderer: &Renderer,
313 _clipboard: &mut dyn Clipboard,
314 shell: &mut Shell<'_, Message>,
315 _viewport: &Rectangle,
316 ) {
317 if !self.enabled {
318 return;
319 }
320 let state = tree.state.downcast_mut::<PickerState>();
321 let over = cursor.is_over(layout.bounds());
322 if over != state.hovered {
323 state.hovered = over;
324 shell.request_redraw();
325 }
326 let pressed = matches!(
327 event,
328 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
329 | Event::Touch(touch::Event::FingerPressed { .. })
330 );
331 if pressed && over {
332 state.panel.open = !state.panel.open;
333 shell.capture_event();
334 shell.request_redraw();
335 }
336 }
337
338 fn mouse_interaction(
339 &self,
340 _tree: &Tree,
341 layout: Layout<'_>,
342 cursor: mouse::Cursor,
343 _viewport: &Rectangle,
344 _renderer: &Renderer,
345 ) -> mouse::Interaction {
346 if self.enabled && cursor.is_over(layout.bounds()) {
347 mouse::Interaction::Pointer
348 } else {
349 mouse::Interaction::default()
350 }
351 }
352
353 fn overlay<'b>(
354 &'b mut self,
355 tree: &'b mut Tree,
356 layout: Layout<'b>,
357 _renderer: &Renderer,
358 _viewport: &Rectangle,
359 translation: Vector,
360 ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
361 let Tree {
362 state, children, ..
363 } = tree;
364 let state = state.downcast_mut::<PickerState>();
365 if !self.enabled || !state.panel.open {
366 return None;
367 }
368 let list_tree = children.get_mut(1)?;
369 Some(anchored::floating(
370 &mut self.list,
371 list_tree,
372 &mut state.panel,
373 &self.behaviour,
374 layout.bounds() + translation,
375 ))
376 }
377}
378
379fn field_status(open: bool, hovered: bool) -> Status {
382 match (open, hovered) {
383 (true, is_hovered) => Status::Opened { is_hovered },
384 (false, true) => Status::Hovered,
385 (false, false) => Status::Active,
386 }
387}
388
389pub fn style(tokens: &Tokens, status: Status, enabled: bool) -> pick_list::Style {
391 let border = match status {
392 Status::Active => tokens.border,
393 Status::Hovered => mix(tokens.border, tokens.foreground, 0.3),
394 Status::Opened { .. } => mix(tokens.border, tokens.foreground, 0.6),
395 };
396 let style = pick_list::Style {
397 text_color: tokens.foreground,
398 placeholder_color: tokens.muted_foreground,
399 handle_color: tokens.muted_foreground,
400 background: Background::Color(Color::TRANSPARENT),
402 border: Border {
403 color: border,
404 width: 1.0,
405 radius: radius::MD.into(),
406 },
407 };
408
409 if enabled {
410 return style;
411 }
412 pick_list::Style {
413 text_color: fade(tokens.foreground, 0.5),
414 placeholder_color: fade(tokens.muted_foreground, 0.5),
415 handle_color: fade(tokens.muted_foreground, 0.5),
416 background: Background::Color(tokens.disabled_field()),
417 border: Border {
418 color: fade(tokens.border, 0.5),
419 ..style.border
420 },
421 }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
426pub enum Action {
427 Next,
428 Previous,
429}
430
431impl Action {
432 pub fn apply<T: PartialEq + Clone>(self, options: &[T], selected: Option<&T>) -> Option<T> {
437 let current = selected.and_then(|value| options.iter().position(|option| option == value));
438 let target = match (self, current) {
439 (Action::Next, None) => 0,
440 (Action::Previous, None) => options.len().checked_sub(1)?,
441 (Action::Next, Some(index)) => index + 1,
442 (Action::Previous, Some(index)) => index.checked_sub(1)?,
443 };
444 options.get(target).cloned()
445 }
446}
447
448impl keys::Action for Action {
449 const ALL: &'static [Self] = &[Action::Next, Action::Previous];
450
451 fn defaults() -> Keymap<Self> {
452 default_keymap()
453 }
454
455 fn name(self) -> &'static str {
456 match self {
457 Action::Next => "Next",
458 Action::Previous => "Previous",
459 }
460 }
461
462 fn description(self) -> &'static str {
463 match self {
464 Action::Next => "Selects the next option, stopping at the last.",
465 Action::Previous => "Selects the previous option, stopping at the first.",
466 }
467 }
468}
469
470pub fn default_keymap() -> Keymap<Action> {
481 Keymap::new()
482 .bind(Chord::named(Named::ArrowDown), Action::Next)
483 .bind(Chord::named(Named::ArrowUp), Action::Previous)
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::theme::{dark, light};
490
491 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
492 let chord: Chord = chord.parse().unwrap();
493 keymap.resolve(chord.key(), chord.modifiers())
494 }
495
496 #[test]
497 fn default_keymap_and_overrides() {
498 let keymap = default_keymap();
499 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
500 assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
501 let custom = keymap
502 .bind("J".parse().unwrap(), Action::Next)
503 .unbind(&"ArrowDown".parse().unwrap());
504 assert_eq!(press(&custom, "j"), Some(Action::Next));
505 assert_eq!(press(&custom, "ArrowDown"), None);
506 }
507
508 #[test]
509 fn next_and_previous_stop_at_the_ends() {
510 let options = ["a", "b", "c"];
511 assert_eq!(Action::Next.apply(&options, Some(&"a")), Some("b"));
512 assert_eq!(Action::Next.apply(&options, Some(&"c")), None);
513 assert_eq!(Action::Previous.apply(&options, Some(&"b")), Some("a"));
514 assert_eq!(Action::Previous.apply(&options, Some(&"a")), None);
515 }
516
517 #[test]
518 fn without_a_selection_next_and_previous_pick_an_end() {
519 let options = ["a", "b", "c"];
520 assert_eq!(Action::Next.apply(&options, None), Some("a"));
521 assert_eq!(Action::Previous.apply(&options, None), Some("c"));
522 assert_eq!(Action::Next.apply(&options, Some(&"z")), Some("a"));
523 let empty: [&str; 0] = [];
524 assert_eq!(Action::Next.apply(&empty, None), None);
525 assert_eq!(Action::Previous.apply(&empty, None), None);
526 }
527
528 const STATES: [Status; 4] = [
529 Status::Active,
530 Status::Hovered,
531 Status::Opened { is_hovered: false },
532 Status::Opened { is_hovered: true },
533 ];
534
535 #[test]
536 fn default_builder_has_no_selection_and_is_disabled() {
537 let s: Select<'_, &str, ()> = select(vec!["a", "b"], None);
538 assert_eq!(s.options.len(), 2);
539 assert!(s.selected.is_none());
540 assert!(s.placeholder.is_none());
541 assert_eq!(s.width, Length::Fixed(200.0));
542 assert!(!s.is_enabled());
543 }
544
545 #[test]
546 fn builder_sets_placeholder_and_message() {
547 const OPTIONS: &[u8] = &[1, 2, 3];
548 let s: Select<'_, u8, u8> = select(OPTIONS, Some(2))
549 .placeholder("Pick a number")
550 .on_select(|v| v);
551 assert_eq!(s.placeholder.as_deref(), Some("Pick a number"));
552 assert!(s.is_enabled());
553 assert!(matches!(s.options, Cow::Borrowed(_)));
554 }
555
556 #[test]
557 fn on_select_maybe_enables_only_with_a_message() {
558 let enabled: Select<'_, u8, u8> = select(vec![1, 2], None).on_select_maybe(Some(|v| v));
559 assert!(enabled.is_enabled());
560 let disabled: Select<'_, u8, u8> =
561 select(vec![1, 2], None).on_select_maybe(None::<fn(u8) -> u8>);
562 assert!(!disabled.is_enabled());
563 }
564
565 #[test]
566 fn hover_and_open_strengthen_the_border() {
567 for theme in [light(), dark()] {
568 let tokens = Tokens::of(&theme);
569 let borders = STATES.map(|status| style(&tokens, status, true).border.color);
570 assert_ne!(borders[0], borders[1]);
571 assert_ne!(borders[1], borders[2]);
572 assert_eq!(borders[2], borders[3]);
573 }
574 }
575
576 #[test]
577 fn placeholder_is_muted() {
578 for theme in [light(), dark()] {
579 let tokens = Tokens::of(&theme);
580 let style = style(&tokens, Status::Active, true);
581 assert_eq!(style.placeholder_color, tokens.muted_foreground);
582 assert_ne!(style.placeholder_color, style.text_color);
583 }
584 }
585
586 #[test]
587 fn disabled_fades_text_and_uses_the_disabled_field_background() {
588 for theme in [light(), dark()] {
589 let tokens = Tokens::of(&theme);
590 for status in STATES {
591 let disabled = style(&tokens, status, false);
592 assert!((disabled.text_color.a - tokens.foreground.a * 0.5).abs() < 1e-6);
593 assert_eq!(
594 disabled.background,
595 Background::Color(tokens.disabled_field())
596 );
597 }
598 }
599 }
600
601 #[test]
602 fn an_enabled_trigger_takes_the_colour_of_its_surface() {
603 for theme in [light(), dark()] {
604 let tokens = Tokens::of(&theme);
605 for status in STATES {
606 let enabled = style(&tokens, status, true);
607 assert_eq!(enabled.background, Background::Color(Color::TRANSPARENT));
608 }
609 }
610 }
611
612 #[test]
613 fn options_highlight_with_the_menu_row_style() {
614 use widget::button::Status as Button;
615 for theme in [light(), dark()] {
616 let tokens = Tokens::of(&theme);
617 let idle = option_style(&tokens, Button::Active);
618 assert_eq!(idle.background, None);
619 assert_eq!(idle.text_color, tokens.foreground);
620 for status in [Button::Hovered, Button::Pressed] {
621 let highlighted = option_style(&tokens, status);
622 assert_eq!(
623 highlighted.background,
624 Some(Background::Color(tokens.accent))
625 );
626 assert_eq!(highlighted.text_color, tokens.accent_foreground);
627 assert_eq!(highlighted.border.radius, radius::SM.into());
628 }
629 let disabled = option_style(&tokens, Button::Disabled);
630 assert_eq!(disabled.text_color, tokens.muted_foreground);
631 }
632 }
633
634 #[test]
635 fn field_status_follows_the_list_and_the_pointer() {
636 assert_eq!(field_status(false, false), Status::Active);
637 assert_eq!(field_status(false, true), Status::Hovered);
638 assert_eq!(
639 field_status(true, false),
640 Status::Opened { is_hovered: false }
641 );
642 assert_eq!(
643 field_status(true, true),
644 Status::Opened { is_hovered: true }
645 );
646 }
647}