Skip to main content

iced_cube/forms/
select.rs

1//! A dropdown for choosing one value from a list.
2
3use std::borrow::Cow;
4
5use iced::keyboard::key::Named;
6use iced::overlay::menu;
7use iced::widget::pick_list::{self, Handle, Status};
8use iced::widget::text::LineHeight;
9use iced::widget::{container, stack, svg};
10use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme, Vector};
11
12use crate::icon::tinted;
13use crate::inert::inert;
14use crate::keys::{self, Chord, Keymap};
15use crate::theme::{Tokens, fade, mix, radius, space, text_size};
16
17/// Height of the closed select in logical pixels.
18pub const HEIGHT: f32 = 36.0;
19
20const CHEVRON_SIZE: f32 = 16.0;
21const LINE_HEIGHT: f32 = 20.0;
22
23/// A select builder. Convert it into an [`Element`] to render.
24///
25/// A select without a message is rendered disabled.
26pub struct Select<'a, T: Clone, Message> {
27    options: Cow<'a, [T]>,
28    selected: Option<T>,
29    placeholder: Option<String>,
30    width: Length,
31    on_select: Option<Box<dyn Fn(T) -> Message + 'a>>,
32}
33
34impl<T: Clone + std::fmt::Debug, Message> std::fmt::Debug for Select<'_, T, Message> {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("Select")
37            .field("options", &self.options)
38            .field("selected", &self.selected)
39            .field("placeholder", &self.placeholder)
40            .field("enabled", &self.on_select.is_some())
41            .finish()
42    }
43}
44
45/// Creates a select over `options`, showing `selected` when it is set.
46/// Options are labelled with their `Display` text.
47pub fn select<'a, T, Message>(
48    options: impl Into<Cow<'a, [T]>>,
49    selected: Option<T>,
50) -> Select<'a, T, Message>
51where
52    T: ToString + PartialEq + Clone,
53{
54    Select {
55        options: options.into(),
56        selected,
57        placeholder: None,
58        width: Length::Fixed(200.0),
59        on_select: None,
60    }
61}
62
63impl<'a, T: Clone, Message> Select<'a, T, Message> {
64    /// Text shown while nothing is selected.
65    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
66        self.placeholder = Some(placeholder.into());
67        self
68    }
69
70    pub fn width(mut self, width: impl Into<Length>) -> Self {
71        self.width = width.into();
72        self
73    }
74
75    /// Sets the message emitted with the chosen option.
76    pub fn on_select(mut self, on_select: impl Fn(T) -> Message + 'a) -> Self {
77        self.on_select = Some(Box::new(on_select));
78        self
79    }
80
81    /// Sets the message emitted with the chosen option. `None` disables the
82    /// select.
83    pub fn on_select_maybe(mut self, on_select: Option<impl Fn(T) -> Message + 'a>) -> Self {
84        self.on_select = on_select.map(|f| Box::new(f) as Box<dyn Fn(T) -> Message + 'a>);
85        self
86    }
87
88    pub fn is_enabled(&self) -> bool {
89        self.on_select.is_some()
90    }
91}
92
93impl<'a, T, Message> From<Select<'a, T, Message>> for Element<'a, Message>
94where
95    T: ToString + PartialEq + Clone + 'a,
96    Message: Clone + 'a,
97{
98    fn from(select: Select<'a, T, Message>) -> Self {
99        let enabled = select.is_enabled();
100        let placeholder = select.placeholder.unwrap_or_default();
101        let options = select.options;
102        let selected = select.selected;
103
104        let list: Element<'a, Message> = match select.on_select {
105            Some(on_select) => field(options, selected, placeholder, on_select, true).into(),
106            None => inert(field(options, selected, placeholder, |_| (), false).into()),
107        };
108
109        let chevron = tinted(crate::lucide!(ChevronDown), CHEVRON_SIZE, None).style(
110            move |theme: &Theme, _| svg::Style {
111                color: Some(style(&Tokens::of(theme), Status::Active, enabled).handle_color),
112            },
113        );
114
115        stack![
116            container(list).width(select.width),
117            container(chevron)
118                .width(Length::Fill)
119                .height(Length::Fill)
120                .align_x(Alignment::End)
121                .align_y(Alignment::Center)
122                .padding(Padding::default().right(space::MD)),
123        ]
124        .into()
125    }
126}
127
128type PickList<'a, T, Message> =
129    iced::widget::PickList<'a, T, Cow<'a, [T]>, T, Message, Theme, iced::Renderer>;
130
131fn field<'a, T, Message>(
132    options: Cow<'a, [T]>,
133    selected: Option<T>,
134    placeholder: String,
135    on_select: impl Fn(T) -> Message + 'a,
136    enabled: bool,
137) -> PickList<'a, T, Message>
138where
139    T: ToString + PartialEq + Clone + 'a,
140    Message: Clone,
141{
142    let vertical = (HEIGHT - LINE_HEIGHT) / 2.0;
143    iced::widget::pick_list(options, selected, on_select)
144        .placeholder(placeholder)
145        .width(Length::Fill)
146        .padding(Padding {
147            top: vertical,
148            bottom: vertical,
149            left: space::MD,
150            right: space::MD + CHEVRON_SIZE + space::SM,
151        })
152        .text_size(text_size::SM)
153        .text_line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
154        .handle(Handle::None)
155        .style(move |theme, status| style(&Tokens::of(theme), status, enabled))
156        .menu_style(|theme| menu_style(&Tokens::of(theme)))
157}
158
159/// The iced pick list style for a status.
160pub fn style(tokens: &Tokens, status: Status, enabled: bool) -> pick_list::Style {
161    let border = match status {
162        Status::Active => tokens.border,
163        Status::Hovered => mix(tokens.border, tokens.foreground, 0.3),
164        Status::Opened { .. } => mix(tokens.border, tokens.foreground, 0.6),
165    };
166    let style = pick_list::Style {
167        text_color: tokens.foreground,
168        placeholder_color: tokens.muted_foreground,
169        handle_color: tokens.muted_foreground,
170        background: Background::Color(tokens.background),
171        border: Border {
172            color: border,
173            width: 1.0,
174            radius: radius::MD.into(),
175        },
176    };
177
178    if enabled {
179        return style;
180    }
181    pick_list::Style {
182        text_color: fade(tokens.foreground, 0.5),
183        placeholder_color: fade(tokens.muted_foreground, 0.5),
184        handle_color: fade(tokens.muted_foreground, 0.5),
185        background: Background::Color(tokens.muted),
186        border: Border {
187            color: fade(tokens.border, 0.5),
188            ..style.border
189        },
190    }
191}
192
193/// The style of the open menu.
194pub fn menu_style(tokens: &Tokens) -> menu::Style {
195    menu::Style {
196        background: Background::Color(tokens.background),
197        border: Border {
198            color: tokens.border,
199            width: 1.0,
200            radius: radius::MD.into(),
201        },
202        text_color: tokens.foreground,
203        selected_text_color: tokens.accent_foreground,
204        selected_background: Background::Color(tokens.accent),
205        shadow: Shadow {
206            color: fade(Color::BLACK, if tokens.is_dark { 0.5 } else { 0.1 }),
207            offset: Vector::new(0.0, 4.0),
208            blur_radius: 12.0,
209        },
210    }
211}
212
213/// What a select keyboard shortcut does.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum Action {
216    Next,
217    Previous,
218}
219
220impl Action {
221    /// The option to select after this action, to send through the
222    /// select's `on_select` message. Stops at the ends of the list, and
223    /// returns `None` when the selection would not change. With nothing
224    /// selected, `Next` picks the first option and `Previous` the last.
225    pub fn apply<T: PartialEq + Clone>(self, options: &[T], selected: Option<&T>) -> Option<T> {
226        let current = selected.and_then(|value| options.iter().position(|option| option == value));
227        let target = match (self, current) {
228            (Action::Next, None) => 0,
229            (Action::Previous, None) => options.len().checked_sub(1)?,
230            (Action::Next, Some(index)) => index + 1,
231            (Action::Previous, Some(index)) => index.checked_sub(1)?,
232        };
233        options.get(target).cloned()
234    }
235}
236
237impl keys::Action for Action {
238    const ALL: &'static [Self] = &[Action::Next, Action::Previous];
239
240    fn defaults() -> Keymap<Self> {
241        default_keymap()
242    }
243
244    fn name(self) -> &'static str {
245        match self {
246            Action::Next => "Next",
247            Action::Previous => "Previous",
248        }
249    }
250
251    fn description(self) -> &'static str {
252        match self {
253            Action::Next => "Selects the next option, stopping at the last.",
254            Action::Previous => "Selects the previous option, stopping at the first.",
255        }
256    }
257}
258
259/// The default select shortcuts:
260///
261/// | Keys | Action |
262/// | --- | --- |
263/// | `ArrowDown` | [`Action::Next`] |
264/// | `ArrowUp` | [`Action::Previous`] |
265///
266/// These change the value without opening the menu. Shortcuts are
267/// app-wide, so an app with several selects routes them to the one it
268/// considers current.
269pub fn default_keymap() -> Keymap<Action> {
270    Keymap::new()
271        .bind(Chord::named(Named::ArrowDown), Action::Next)
272        .bind(Chord::named(Named::ArrowUp), Action::Previous)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::theme::{dark, light};
279
280    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
281        let chord: Chord = chord.parse().unwrap();
282        keymap.resolve(chord.key(), chord.modifiers())
283    }
284
285    #[test]
286    fn default_keymap_and_overrides() {
287        let keymap = default_keymap();
288        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
289        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
290        let custom = keymap
291            .bind("J".parse().unwrap(), Action::Next)
292            .unbind(&"ArrowDown".parse().unwrap());
293        assert_eq!(press(&custom, "j"), Some(Action::Next));
294        assert_eq!(press(&custom, "ArrowDown"), None);
295    }
296
297    #[test]
298    fn next_and_previous_stop_at_the_ends() {
299        let options = ["a", "b", "c"];
300        assert_eq!(Action::Next.apply(&options, Some(&"a")), Some("b"));
301        assert_eq!(Action::Next.apply(&options, Some(&"c")), None);
302        assert_eq!(Action::Previous.apply(&options, Some(&"b")), Some("a"));
303        assert_eq!(Action::Previous.apply(&options, Some(&"a")), None);
304    }
305
306    #[test]
307    fn without_a_selection_next_and_previous_pick_an_end() {
308        let options = ["a", "b", "c"];
309        assert_eq!(Action::Next.apply(&options, None), Some("a"));
310        assert_eq!(Action::Previous.apply(&options, None), Some("c"));
311        assert_eq!(Action::Next.apply(&options, Some(&"z")), Some("a"));
312        let empty: [&str; 0] = [];
313        assert_eq!(Action::Next.apply(&empty, None), None);
314        assert_eq!(Action::Previous.apply(&empty, None), None);
315    }
316
317    const STATES: [Status; 4] = [
318        Status::Active,
319        Status::Hovered,
320        Status::Opened { is_hovered: false },
321        Status::Opened { is_hovered: true },
322    ];
323
324    #[test]
325    fn default_builder_has_no_selection_and_is_disabled() {
326        let s: Select<'_, &str, ()> = select(vec!["a", "b"], None);
327        assert_eq!(s.options.len(), 2);
328        assert!(s.selected.is_none());
329        assert!(s.placeholder.is_none());
330        assert_eq!(s.width, Length::Fixed(200.0));
331        assert!(!s.is_enabled());
332    }
333
334    #[test]
335    fn builder_sets_placeholder_and_message() {
336        const OPTIONS: &[u8] = &[1, 2, 3];
337        let s: Select<'_, u8, u8> = select(OPTIONS, Some(2))
338            .placeholder("Pick a number")
339            .on_select(|v| v);
340        assert_eq!(s.placeholder.as_deref(), Some("Pick a number"));
341        assert!(s.is_enabled());
342        assert!(matches!(s.options, Cow::Borrowed(_)));
343    }
344
345    #[test]
346    fn on_select_maybe_enables_only_with_a_message() {
347        let enabled: Select<'_, u8, u8> = select(vec![1, 2], None).on_select_maybe(Some(|v| v));
348        assert!(enabled.is_enabled());
349        let disabled: Select<'_, u8, u8> =
350            select(vec![1, 2], None).on_select_maybe(None::<fn(u8) -> u8>);
351        assert!(!disabled.is_enabled());
352    }
353
354    #[test]
355    fn hover_and_open_strengthen_the_border() {
356        for theme in [light(), dark()] {
357            let tokens = Tokens::of(&theme);
358            let borders = STATES.map(|status| style(&tokens, status, true).border.color);
359            assert_ne!(borders[0], borders[1]);
360            assert_ne!(borders[1], borders[2]);
361            assert_eq!(borders[2], borders[3]);
362        }
363    }
364
365    #[test]
366    fn placeholder_is_muted() {
367        for theme in [light(), dark()] {
368            let tokens = Tokens::of(&theme);
369            let style = style(&tokens, Status::Active, true);
370            assert_eq!(style.placeholder_color, tokens.muted_foreground);
371            assert_ne!(style.placeholder_color, style.text_color);
372        }
373    }
374
375    #[test]
376    fn disabled_fades_text_and_uses_a_muted_background() {
377        for theme in [light(), dark()] {
378            let tokens = Tokens::of(&theme);
379            for status in STATES {
380                let disabled = style(&tokens, status, false);
381                assert!((disabled.text_color.a - tokens.foreground.a * 0.5).abs() < 1e-6);
382                assert_eq!(disabled.background, Background::Color(tokens.muted));
383            }
384        }
385    }
386
387    #[test]
388    fn menu_highlights_with_accent() {
389        for theme in [light(), dark()] {
390            let tokens = Tokens::of(&theme);
391            let menu = menu_style(&tokens);
392            assert_eq!(menu.selected_background, Background::Color(tokens.accent));
393            assert_eq!(menu.border.width, 1.0);
394        }
395    }
396}