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, Color, 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        // Transparent so the trigger takes the colour of its surface.
407        background: Background::Color(Color::TRANSPARENT),
408        border: Border {
409            color: border,
410            width: 1.0,
411            radius: radius::MD.into(),
412        },
413    };
414
415    if enabled {
416        return style;
417    }
418    pick_list::Style {
419        text_color: fade(tokens.foreground, 0.5),
420        placeholder_color: fade(tokens.muted_foreground, 0.5),
421        handle_color: fade(tokens.muted_foreground, 0.5),
422        background: Background::Color(tokens.disabled_field()),
423        border: Border {
424            color: fade(tokens.border, 0.5),
425            ..style.border
426        },
427    }
428}
429
430/// What a select keyboard shortcut does.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
432pub enum Action {
433    Next,
434    Previous,
435}
436
437impl Action {
438    /// The option to select after this action, to send through the
439    /// select's `on_select` message. Stops at the ends of the list, and
440    /// returns `None` when the selection would not change. With nothing
441    /// selected, `Next` picks the first option and `Previous` the last.
442    pub fn apply<T: PartialEq + Clone>(self, options: &[T], selected: Option<&T>) -> Option<T> {
443        let current = selected.and_then(|value| options.iter().position(|option| option == value));
444        let target = match (self, current) {
445            (Action::Next, None) => 0,
446            (Action::Previous, None) => options.len().checked_sub(1)?,
447            (Action::Next, Some(index)) => index + 1,
448            (Action::Previous, Some(index)) => index.checked_sub(1)?,
449        };
450        options.get(target).cloned()
451    }
452}
453
454impl keys::Action for Action {
455    const ALL: &'static [Self] = &[Action::Next, Action::Previous];
456
457    fn defaults() -> Keymap<Self> {
458        default_keymap()
459    }
460
461    fn name(self) -> &'static str {
462        match self {
463            Action::Next => "Next",
464            Action::Previous => "Previous",
465        }
466    }
467
468    fn description(self) -> &'static str {
469        match self {
470            Action::Next => "Selects the next option, stopping at the last.",
471            Action::Previous => "Selects the previous option, stopping at the first.",
472        }
473    }
474}
475
476/// The default select shortcuts:
477///
478/// | Keys | Action |
479/// | --- | --- |
480/// | `ArrowDown` | [`Action::Next`] |
481/// | `ArrowUp` | [`Action::Previous`] |
482///
483/// These change the value without opening the menu. Shortcuts are
484/// app-wide, so an app with several selects routes them to the one it
485/// considers current.
486pub fn default_keymap() -> Keymap<Action> {
487    Keymap::new()
488        .bind(Chord::named(Named::ArrowDown), Action::Next)
489        .bind(Chord::named(Named::ArrowUp), Action::Previous)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::theme::{dark, light};
496
497    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
498        let chord: Chord = chord.parse().unwrap();
499        keymap.resolve(chord.key(), chord.modifiers())
500    }
501
502    #[test]
503    fn default_keymap_and_overrides() {
504        let keymap = default_keymap();
505        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
506        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
507        let custom = keymap
508            .bind("J".parse().unwrap(), Action::Next)
509            .unbind(&"ArrowDown".parse().unwrap());
510        assert_eq!(press(&custom, "j"), Some(Action::Next));
511        assert_eq!(press(&custom, "ArrowDown"), None);
512    }
513
514    #[test]
515    fn next_and_previous_stop_at_the_ends() {
516        let options = ["a", "b", "c"];
517        assert_eq!(Action::Next.apply(&options, Some(&"a")), Some("b"));
518        assert_eq!(Action::Next.apply(&options, Some(&"c")), None);
519        assert_eq!(Action::Previous.apply(&options, Some(&"b")), Some("a"));
520        assert_eq!(Action::Previous.apply(&options, Some(&"a")), None);
521    }
522
523    #[test]
524    fn without_a_selection_next_and_previous_pick_an_end() {
525        let options = ["a", "b", "c"];
526        assert_eq!(Action::Next.apply(&options, None), Some("a"));
527        assert_eq!(Action::Previous.apply(&options, None), Some("c"));
528        assert_eq!(Action::Next.apply(&options, Some(&"z")), Some("a"));
529        let empty: [&str; 0] = [];
530        assert_eq!(Action::Next.apply(&empty, None), None);
531        assert_eq!(Action::Previous.apply(&empty, None), None);
532    }
533
534    const STATES: [Status; 4] = [
535        Status::Active,
536        Status::Hovered,
537        Status::Opened { is_hovered: false },
538        Status::Opened { is_hovered: true },
539    ];
540
541    #[test]
542    fn default_builder_has_no_selection_and_is_disabled() {
543        let s: Select<'_, &str, ()> = select(vec!["a", "b"], None);
544        assert_eq!(s.options.len(), 2);
545        assert!(s.selected.is_none());
546        assert!(s.placeholder.is_none());
547        assert_eq!(s.width, Length::Fixed(200.0));
548        assert!(!s.is_enabled());
549    }
550
551    #[test]
552    fn builder_sets_placeholder_and_message() {
553        const OPTIONS: &[u8] = &[1, 2, 3];
554        let s: Select<'_, u8, u8> = select(OPTIONS, Some(2))
555            .placeholder("Pick a number")
556            .on_select(|v| v);
557        assert_eq!(s.placeholder.as_deref(), Some("Pick a number"));
558        assert!(s.is_enabled());
559        assert!(matches!(s.options, Cow::Borrowed(_)));
560    }
561
562    #[test]
563    fn on_select_maybe_enables_only_with_a_message() {
564        let enabled: Select<'_, u8, u8> = select(vec![1, 2], None).on_select_maybe(Some(|v| v));
565        assert!(enabled.is_enabled());
566        let disabled: Select<'_, u8, u8> =
567            select(vec![1, 2], None).on_select_maybe(None::<fn(u8) -> u8>);
568        assert!(!disabled.is_enabled());
569    }
570
571    #[test]
572    fn hover_and_open_strengthen_the_border() {
573        for theme in [light(), dark()] {
574            let tokens = Tokens::of(&theme);
575            let borders = STATES.map(|status| style(&tokens, status, true).border.color);
576            assert_ne!(borders[0], borders[1]);
577            assert_ne!(borders[1], borders[2]);
578            assert_eq!(borders[2], borders[3]);
579        }
580    }
581
582    #[test]
583    fn placeholder_is_muted() {
584        for theme in [light(), dark()] {
585            let tokens = Tokens::of(&theme);
586            let style = style(&tokens, Status::Active, true);
587            assert_eq!(style.placeholder_color, tokens.muted_foreground);
588            assert_ne!(style.placeholder_color, style.text_color);
589        }
590    }
591
592    #[test]
593    fn disabled_fades_text_and_uses_the_disabled_field_background() {
594        for theme in [light(), dark()] {
595            let tokens = Tokens::of(&theme);
596            for status in STATES {
597                let disabled = style(&tokens, status, false);
598                assert!((disabled.text_color.a - tokens.foreground.a * 0.5).abs() < 1e-6);
599                assert_eq!(
600                    disabled.background,
601                    Background::Color(tokens.disabled_field())
602                );
603            }
604        }
605    }
606
607    #[test]
608    fn an_enabled_trigger_takes_the_colour_of_its_surface() {
609        for theme in [light(), dark()] {
610            let tokens = Tokens::of(&theme);
611            for status in STATES {
612                let enabled = style(&tokens, status, true);
613                assert_eq!(enabled.background, Background::Color(Color::TRANSPARENT));
614            }
615        }
616    }
617
618    #[test]
619    fn options_highlight_with_the_menu_row_style() {
620        use widget::button::Status as Button;
621        for theme in [light(), dark()] {
622            let tokens = Tokens::of(&theme);
623            let idle = option_style(&tokens, Button::Active);
624            assert_eq!(idle.background, None);
625            assert_eq!(idle.text_color, tokens.foreground);
626            for status in [Button::Hovered, Button::Pressed] {
627                let highlighted = option_style(&tokens, status);
628                assert_eq!(
629                    highlighted.background,
630                    Some(Background::Color(tokens.accent))
631                );
632                assert_eq!(highlighted.text_color, tokens.accent_foreground);
633                assert_eq!(highlighted.border.radius, radius::SM.into());
634            }
635            let disabled = option_style(&tokens, Button::Disabled);
636            assert_eq!(disabled.text_color, tokens.muted_foreground);
637        }
638    }
639
640    #[test]
641    fn field_status_follows_the_list_and_the_pointer() {
642        assert_eq!(field_status(false, false), Status::Active);
643        assert_eq!(field_status(false, true), Status::Hovered);
644        assert_eq!(
645            field_status(true, false),
646            Status::Opened { is_hovered: false }
647        );
648        assert_eq!(
649            field_status(true, true),
650            Status::Opened { is_hovered: true }
651        );
652    }
653}