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::{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
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        // A long label ends in an ellipsis rather than running under the chevron.
137        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
169/// One option in the open list, highlighted under the pointer.
170fn 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
193/// An option row: the shared menu row, highlighted while hovered or pressed.
194pub 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
213/// The select widget: draws the field and floats the list while open.
214struct 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
379/// The field's status: opened while the list shows, hovered under the
380/// pointer, active otherwise.
381fn 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
389/// The iced pick list style for a status.
390pub 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        // Transparent so the trigger takes the colour of its surface.
401        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/// What a select keyboard shortcut does.
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
426pub enum Action {
427    Next,
428    Previous,
429}
430
431impl Action {
432    /// The option to select after this action, to send through the
433    /// select's `on_select` message. Stops at the ends of the list, and
434    /// returns `None` when the selection would not change. With nothing
435    /// selected, `Next` picks the first option and `Previous` the last.
436    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
470/// The default select shortcuts:
471///
472/// | Keys | Action |
473/// | --- | --- |
474/// | `ArrowDown` | [`Action::Next`] |
475/// | `ArrowUp` | [`Action::Previous`] |
476///
477/// These change the value without opening the menu. Shortcuts are
478/// app-wide, so an app with several selects routes them to the one it
479/// considers current.
480pub 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}