Skip to main content

iced_cube/forms/
select.rs

1//! A dropdown for choosing one value from a list.
2//!
3//! The select opens and closes its own list, so the app only handles the
4//! chosen value. The list floats on the shared
5//! [anchored layer](crate::overlay::anchored) in the
6//! [menu look](crate::overlay::menu), with a check mark on the selected
7//! option. Escape or a click outside closes it.
8
9use 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
29/// Height of the closed select in logical pixels.
30pub const HEIGHT: f32 = 36.0;
31
32const CHEVRON_SIZE: f32 = 16.0;
33const LINE_HEIGHT: f32 = 20.0;
34
35/// A select builder. Convert it into an [`Element`] to render.
36///
37/// A select without a message is rendered disabled.
38pub 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
57/// Creates a select over `options`, showing `selected` when it is set.
58/// Options are labelled with their `Display` text.
59pub 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    /// Text shown while nothing is selected.
77    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    /// Sets the message emitted with the chosen option.
88    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    /// Sets the message emitted with the chosen option. `None` disables the
94    /// select.
95    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
175/// One option in the open list, highlighted under the pointer.
176fn 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
199/// An option row: the shared menu row, highlighted while hovered or pressed.
200pub 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
219/// The select widget: draws the field and floats the list while open.
220struct 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
385/// The field's status: opened while the list shows, hovered under the
386/// pointer, active otherwise.
387fn 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
395/// The iced pick list style for a status.
396pub 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/// What a select keyboard shortcut does.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431pub enum Action {
432    Next,
433    Previous,
434}
435
436impl Action {
437    /// The option to select after this action, to send through the
438    /// select's `on_select` message. Stops at the ends of the list, and
439    /// returns `None` when the selection would not change. With nothing
440    /// selected, `Next` picks the first option and `Previous` the last.
441    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
475/// The default select shortcuts:
476///
477/// | Keys | Action |
478/// | --- | --- |
479/// | `ArrowDown` | [`Action::Next`] |
480/// | `ArrowUp` | [`Action::Previous`] |
481///
482/// These change the value without opening the menu. Shortcuts are
483/// app-wide, so an app with several selects routes them to the one it
484/// considers current.
485pub 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}