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