iced_searchable_picklist/
lib.rs

1#![allow(clippy::too_many_arguments)]
2
3//! Display a dropdown list of selectable values.
4use iced_native::alignment;
5use iced_native::event::{self, Event};
6use iced_native::keyboard;
7use iced_native::layout;
8use iced_native::mouse;
9use iced_native::overlay;
10use iced_native::overlay::menu::{self, Menu};
11use iced_native::renderer;
12use iced_native::text::{self, Text};
13use iced_native::touch;
14use iced_native::widget::text_input::{self, Id, Value};
15use iced_native::widget::{container, operation, scrollable, tree, Tree};
16use iced_native::{
17    Clipboard, Element, Layout, Length, Padding, Point, Rectangle, Shell, Size, Widget,
18};
19use std::borrow::Cow;
20
21pub use iced_style::pick_list::StyleSheet;
22
23/// A widget for selecting a single value from a list of options.
24#[allow(missing_debug_implementations)]
25pub struct PickList<'a, T: 'static, Message, Renderer: text::Renderer>
26where
27    [T]: ToOwned<Owned = Vec<T>>,
28    Message: Clone,
29    Renderer::Theme: StyleSheet
30        + scrollable::StyleSheet
31        + menu::StyleSheet
32        + container::StyleSheet
33        + text_input::StyleSheet,
34    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
35{
36    id: Option<Id>,
37    on_selected: Box<dyn Fn(T) -> Message>,
38    options: Cow<'a, [T]>,
39    placeholder: Option<String>,
40    selected: Option<T>,
41    width: Length,
42    padding: Padding,
43    text_size: Option<u16>,
44    font: Renderer::Font,
45    style_sheet: <Renderer::Theme as StyleSheet>::Style,
46    text_style_sheet: <Renderer::Theme as text_input::StyleSheet>::Style,
47    value: text_input::Value,
48    on_change: Box<dyn Fn(String) -> Message>,
49    on_submit: Option<Message>,
50    on_paste: Option<Box<dyn Fn(String) -> Message>>,
51    on_focus: Option<Message>,
52}
53
54/// The local state of a [`PickList`].
55#[derive(Debug)]
56pub struct State<T> {
57    menu: menu::State,
58    keyboard_modifiers: keyboard::Modifiers,
59    is_open: bool,
60    hovered_option: Option<usize>,
61    last_selection: Option<T>,
62    text_input: text_input::State,
63}
64
65impl<T> State<T> {
66    /// Creates a new [`State`] for a [`PickList`].
67    pub fn new() -> Self {
68        Self {
69            menu: menu::State::default(),
70            keyboard_modifiers: keyboard::Modifiers::default(),
71            is_open: bool::default(),
72            hovered_option: Option::default(),
73            last_selection: Option::default(),
74            text_input: text_input::State::default(),
75        }
76    }
77
78    /// Focus the text_input of the [`PickList`].
79    pub fn focus(&mut self) {
80        self.text_input.focus();
81    }
82
83    /// Unfocus the text_input of the [`PickList`].
84    pub fn unfocus(&mut self) {
85        self.text_input.unfocus();
86        self.is_open = false;
87    }
88
89    /// Pick the specified element from the [`PickList`].
90    pub fn pick(&mut self, element: T) {
91        self.is_open = false;
92        self.last_selection = Some(element);
93
94        self.unfocus();
95    }
96}
97
98impl<T> operation::Focusable for State<T> {
99    fn is_focused(&self) -> bool {
100        self.text_input.is_focused()
101    }
102
103    fn focus(&mut self) {
104        State::focus(self)
105    }
106
107    fn unfocus(&mut self) {
108        State::unfocus(self)
109    }
110}
111
112impl<T> Default for State<T> {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl<'a, T: 'a, Message, Renderer: text::Renderer> PickList<'a, T, Message, Renderer>
119where
120    T: ToString + Eq,
121    [T]: ToOwned<Owned = Vec<T>>,
122    Message: Clone,
123    Renderer::Theme: StyleSheet
124        + scrollable::StyleSheet
125        + menu::StyleSheet
126        + container::StyleSheet
127        + text_input::StyleSheet,
128    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
129{
130    /// The default padding of a [`PickList`].
131    pub const DEFAULT_PADDING: Padding = Padding::new(5);
132
133    /// Creates a new [`PickList`] with the given [`State`], a list of options,
134    /// the current selected value, and the message to produce when an option is
135    /// selected.
136    pub fn new(
137        options: impl Into<Cow<'a, [T]>>,
138        selected: Option<T>,
139        on_selected: impl Fn(T) -> Message + 'static,
140        on_change: impl Fn(String) -> Message + 'static,
141        value: &str,
142    ) -> Self {
143        Self {
144            id: None,
145            on_selected: Box::new(on_selected),
146            options: options.into(),
147            placeholder: None,
148            selected,
149            width: Length::Shrink,
150            text_size: None,
151            padding: Self::DEFAULT_PADDING,
152            font: Default::default(),
153            style_sheet: Default::default(),
154            text_style_sheet: Default::default(),
155            value: Value::new(value),
156            on_change: Box::new(on_change),
157            on_submit: None,
158            on_paste: None,
159            on_focus: None,
160        }
161    }
162
163    /// Sets the [`Id`] of the [`TextInput`].
164    pub fn id(mut self, id: Id) -> Self {
165        self.id = Some(id);
166        self
167    }
168
169    /// Sets the placeholder of the [`PickList`].
170    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
171        self.placeholder = Some(placeholder.into());
172        self
173    }
174
175    /// Sets the on_submit Message of the [`PickList`].
176    pub fn on_submit(mut self, on_submit: Message) -> Self {
177        self.on_submit = Some(on_submit);
178        self
179    }
180
181    /// Sets the on_submit Message of the [`PickList`].
182    pub fn on_focus(mut self, on_focus: Message) -> Self {
183        self.on_focus = Some(on_focus);
184        self
185    }
186
187    /// Sets the width of the [`PickList`].
188    pub fn width(mut self, width: Length) -> Self {
189        self.width = width;
190        self
191    }
192
193    /// Sets the [`Padding`] of the [`PickList`].
194    pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
195        self.padding = padding.into();
196        self
197    }
198
199    /// Sets the text size of the [`PickList`].
200    pub fn text_size(mut self, size: u16) -> Self {
201        self.text_size = Some(size);
202        self
203    }
204
205    /// Sets the font of the [`PickList`].
206    pub fn font(mut self, font: Renderer::Font) -> Self {
207        self.font = font;
208        self
209    }
210
211    /// Sets the style of the [`PickList`].
212    pub fn style(mut self, style: impl Into<<Renderer::Theme as StyleSheet>::Style>) -> Self {
213        self.style_sheet = style.into();
214        self
215    }
216
217    /// Sets the style of the [`PickList`].
218    pub fn text_style(
219        mut self,
220        style: impl Into<<Renderer::Theme as text_input::StyleSheet>::Style>,
221    ) -> Self {
222        self.text_style_sheet = style.into();
223        self
224    }
225}
226
227/// Computes the layout of a [`PickList`].
228pub fn layout<Renderer, T>(
229    renderer: &Renderer,
230    limits: &layout::Limits,
231    width: Length,
232    padding: Padding,
233    text_size: Option<u16>,
234    font: &Renderer::Font,
235    placeholder: Option<&str>,
236    options: &[T],
237) -> layout::Node
238where
239    Renderer: text::Renderer,
240    T: ToString,
241    Renderer::Theme: StyleSheet
242        + scrollable::StyleSheet
243        + menu::StyleSheet
244        + container::StyleSheet
245        + text_input::StyleSheet,
246    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
247{
248    use std::f32;
249
250    let limits = limits.width(width).height(Length::Shrink).pad(padding);
251
252    let text_size = text_size.unwrap_or_else(|| renderer.default_size());
253
254    let max_width = match width {
255        Length::Shrink => {
256            let measure = |label: &str| -> u32 {
257                let (width, _) = renderer.measure(
258                    label,
259                    text_size,
260                    font.clone(),
261                    Size::new(f32::INFINITY, f32::INFINITY),
262                );
263
264                width.round() as u32
265            };
266
267            let labels = options.iter().map(ToString::to_string);
268
269            let labels_width = labels.map(|label| measure(&label)).max().unwrap_or(100);
270
271            let placeholder_width = placeholder.map(measure).unwrap_or(100);
272
273            labels_width.max(placeholder_width)
274        }
275        _ => 0,
276    };
277
278    let size = {
279        let intrinsic = Size::new(
280            max_width as f32 + f32::from(text_size) + f32::from(padding.left),
281            f32::from(text_size),
282        );
283
284        limits.resolve(intrinsic).pad(padding)
285    };
286
287    let mut text = layout::Node::new(limits.resolve(size));
288    text.move_to(Point::new(padding.left.into(), 0.));
289
290    layout::Node::with_children(size, vec![text])
291}
292
293/// Processes an [`Event`] and updates the [`State`] of a [`PickList`]
294/// accordingly.
295pub fn update<'a, T, Message, Renderer>(
296    event: Event,
297    layout: Layout<'_>,
298    cursor_position: Point,
299    shell: &mut Shell<'_, Message>,
300    on_selected: &dyn Fn(T) -> Message,
301    selected: Option<&T>,
302    options: &[T],
303    state: impl FnOnce() -> &'a mut State<T>,
304    renderer: &Renderer,
305    clipboard: &mut dyn Clipboard,
306    value: &mut Value,
307    size: Option<u16>,
308    font: &Renderer::Font,
309    on_change: &dyn Fn(String) -> Message,
310    on_paste: Option<&dyn Fn(String) -> Message>,
311    on_submit: &Option<Message>,
312    on_focus: &Option<Message>,
313) -> event::Status
314where
315    T: PartialEq + Clone + 'a,
316    Message: Clone,
317    Renderer: text::Renderer,
318    Renderer::Theme: StyleSheet
319        + scrollable::StyleSheet
320        + menu::StyleSheet
321        + container::StyleSheet
322        + text_input::StyleSheet,
323    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
324{
325    let state = state();
326    let mut propagate_event = |state: &mut text_input::State| {
327        text_input::update(
328            event.clone(),
329            layout,
330            cursor_position,
331            renderer,
332            clipboard,
333            shell,
334            value,
335            size,
336            font,
337            false,
338            on_change,
339            on_paste,
340            on_submit,
341            || state,
342        )
343    };
344
345    match event.clone() {
346        Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
347        | Event::Touch(touch::Event::FingerPressed { .. }) => {
348            let event_status = if state.is_open {
349                // TODO: Encode cursor availability in the type system
350                if layout.bounds().contains(cursor_position) {
351                    if cursor_position.x >= 0.0 && cursor_position.y >= 0.0 {
352                        state.unfocus();
353                    } else {
354                        propagate_event(&mut state.text_input);
355                    }
356                }
357
358                event::Status::Captured
359            } else if layout.bounds().contains(cursor_position) {
360                state.is_open = true;
361                state.hovered_option = options.iter().position(|option| Some(option) == selected);
362                state.focus();
363                state.text_input.move_cursor_to_end();
364                propagate_event(&mut state.text_input);
365                if let Some(message) = on_focus.as_ref() {
366                    shell.publish(message.clone())
367                }
368
369                event::Status::Captured
370            } else {
371                event::Status::Ignored
372            };
373
374            if let Some(last_selection) = state.last_selection.take() {
375                shell.publish((on_selected)(last_selection));
376
377                state.is_open = false;
378                state.unfocus();
379
380                event::Status::Captured
381            } else {
382                event_status
383            }
384        }
385        Event::Mouse(mouse::Event::WheelScrolled {
386            delta: mouse::ScrollDelta::Lines { y, .. },
387        }) => {
388            if state.keyboard_modifiers.command()
389                && layout.bounds().contains(cursor_position)
390                && !state.is_open
391            {
392                fn find_next<'a, T: PartialEq>(
393                    selected: &'a T,
394                    mut options: impl Iterator<Item = &'a T>,
395                ) -> Option<&'a T> {
396                    let _ = options.find(|&option| option == selected);
397
398                    options.next()
399                }
400
401                let next_option = if y < 0.0 {
402                    if let Some(selected) = selected {
403                        find_next(selected, options.iter())
404                    } else {
405                        options.first()
406                    }
407                } else if y > 0.0 {
408                    if let Some(selected) = selected {
409                        find_next(selected, options.iter().rev())
410                    } else {
411                        options.last()
412                    }
413                } else {
414                    None
415                };
416
417                if let Some(next_option) = next_option {
418                    shell.publish((on_selected)(next_option.clone()));
419                }
420
421                event::Status::Captured
422            } else {
423                event::Status::Ignored
424            }
425        }
426        Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
427            state.keyboard_modifiers = modifiers;
428            propagate_event(&mut state.text_input);
429
430            event::Status::Ignored
431        }
432        _ => propagate_event(&mut state.text_input),
433    }
434}
435
436/// Returns the current [`mouse::Interaction`] of a [`PickList`].
437pub fn mouse_interaction(layout: Layout<'_>, cursor_position: Point) -> mouse::Interaction {
438    let text_bounds = layout.children().next().unwrap().bounds();
439    let bounds = layout.bounds();
440    let is_mouse_over_text = text_bounds.contains(cursor_position);
441    let is_mouse_over = bounds.contains(cursor_position);
442
443    if is_mouse_over_text {
444        mouse::Interaction::Text
445    } else if is_mouse_over {
446        mouse::Interaction::Pointer
447    } else {
448        mouse::Interaction::default()
449    }
450}
451
452/// Returns the current overlay of a [`PickList`].
453pub fn overlay<'a, T, Message, Renderer>(
454    layout: Layout<'_>,
455    state: &'a mut State<T>,
456    padding: Padding,
457    text_size: Option<u16>,
458    font: Renderer::Font,
459    options: &'a [T],
460    style_sheet: <Renderer::Theme as StyleSheet>::Style,
461) -> Option<overlay::Element<'a, Message, Renderer>>
462where
463    Message: 'a,
464    Renderer: text::Renderer + 'a,
465    T: Clone + ToString,
466    Renderer::Theme: StyleSheet
467        + scrollable::StyleSheet
468        + menu::StyleSheet
469        + container::StyleSheet
470        + text_input::StyleSheet,
471    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
472{
473    if state.is_open {
474        let bounds = layout.bounds();
475
476        let mut menu = Menu::new(
477            &mut state.menu,
478            options,
479            &mut state.hovered_option,
480            &mut state.last_selection,
481        )
482        .width(bounds.width.round() as u16)
483        .padding(padding)
484        .font(font)
485        .style(style_sheet);
486
487        if let Some(text_size) = text_size {
488            menu = menu.text_size(text_size);
489        }
490
491        Some(menu.overlay(layout.position(), bounds.height))
492    } else {
493        None
494    }
495}
496
497/// Draws a [`PickList`].
498pub fn draw<T, Renderer>(
499    renderer: &mut Renderer,
500    layout: Layout<'_>,
501    cursor_position: Point,
502    state: &State<T>,
503    value: &Value,
504    padding: Padding,
505    text_size: Option<u16>,
506    font: &Renderer::Font,
507    placeholder: Option<&str>,
508    selected: Option<&T>,
509    style_sheet: &<Renderer::Theme as StyleSheet>::Style,
510    text_style_sheet: &<Renderer::Theme as text_input::StyleSheet>::Style,
511    theme: &Renderer::Theme,
512) where
513    Renderer: text::Renderer,
514    T: ToString,
515    Renderer::Theme: StyleSheet
516        + scrollable::StyleSheet
517        + menu::StyleSheet
518        + container::StyleSheet
519        + text_input::StyleSheet,
520    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
521{
522    let bounds = layout.bounds();
523    let is_mouse_over = bounds.contains(cursor_position);
524    let is_selected = selected.is_some();
525
526    let style = if is_mouse_over {
527        theme.hovered(style_sheet)
528    } else {
529        theme.active(style_sheet)
530    };
531
532    renderer.fill_quad(
533        renderer::Quad {
534            bounds,
535            border_color: style.border_color,
536            border_width: style.border_width,
537            border_radius: style.border_radius,
538        },
539        style.background,
540    );
541
542    renderer.fill_text(Text {
543        content: &Renderer::ARROW_DOWN_ICON.to_string(),
544        font: Renderer::ICON_FONT,
545        size: bounds.height * style.icon_size,
546        bounds: Rectangle {
547            x: bounds.x + bounds.width - f32::from(padding.horizontal()),
548            y: bounds.center_y(),
549            ..bounds
550        },
551        color: style.text_color,
552        horizontal_alignment: alignment::Horizontal::Right,
553        vertical_alignment: alignment::Vertical::Center,
554    });
555
556    let label = selected.map(ToString::to_string);
557
558    if state.text_input.is_focused() {
559        text_input::draw(
560            renderer,
561            theme,
562            layout,
563            cursor_position,
564            &state.text_input,
565            value,
566            placeholder.unwrap_or_default(),
567            text_size,
568            font,
569            false,
570            text_style_sheet,
571        );
572    } else if let Some(label) = label.as_deref().or(placeholder) {
573        let text_size = f32::from(text_size.unwrap_or_else(|| renderer.default_size()));
574
575        renderer.fill_text(Text {
576            content: label,
577            size: text_size,
578            font: font.clone(),
579            color: if is_selected {
580                style.text_color
581            } else {
582                style.placeholder_color
583            },
584            bounds: Rectangle {
585                x: bounds.x + f32::from(padding.left),
586                y: bounds.center_y() - text_size / 2.0,
587                width: bounds.width - f32::from(padding.horizontal()),
588                height: text_size,
589            },
590            horizontal_alignment: alignment::Horizontal::Left,
591            vertical_alignment: alignment::Vertical::Top,
592        });
593    }
594}
595
596impl<'a, T: 'static, Message, Renderer> Widget<Message, Renderer>
597    for PickList<'a, T, Message, Renderer>
598where
599    T: Clone + ToString + Eq,
600    [T]: ToOwned<Owned = Vec<T>>,
601    Message: 'static + Clone,
602    Renderer: text::Renderer + 'a,
603    Renderer::Theme: StyleSheet
604        + scrollable::StyleSheet
605        + menu::StyleSheet
606        + container::StyleSheet
607        + text_input::StyleSheet,
608    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
609{
610    fn tag(&self) -> tree::Tag {
611        tree::Tag::of::<State<T>>()
612    }
613
614    fn state(&self) -> tree::State {
615        tree::State::new(State::<T>::new())
616    }
617
618    fn width(&self) -> Length {
619        self.width
620    }
621
622    fn height(&self) -> Length {
623        Length::Shrink
624    }
625
626    fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
627        layout(
628            renderer,
629            limits,
630            self.width,
631            self.padding,
632            self.text_size,
633            &self.font,
634            self.placeholder.as_deref(),
635            &self.options,
636        )
637    }
638
639    fn on_event(
640        &mut self,
641        tree: &mut Tree,
642        event: Event,
643        layout: Layout<'_>,
644        cursor_position: Point,
645        renderer: &Renderer,
646        clipboard: &mut dyn Clipboard,
647        shell: &mut Shell<'_, Message>,
648    ) -> event::Status {
649        update(
650            event,
651            layout,
652            cursor_position,
653            shell,
654            self.on_selected.as_ref(),
655            self.selected.as_ref(),
656            &self.options,
657            || tree.state.downcast_mut::<State<T>>(),
658            renderer,
659            clipboard,
660            &mut self.value,
661            self.text_size,
662            &self.font,
663            &self.on_change,
664            self.on_paste.as_deref(),
665            &self.on_submit,
666            &self.on_focus,
667        )
668    }
669
670    fn mouse_interaction(
671        &self,
672        _state: &Tree,
673        layout: Layout<'_>,
674        cursor_position: Point,
675        _viewport: &Rectangle,
676        _renderer: &Renderer,
677    ) -> mouse::Interaction {
678        mouse_interaction(layout, cursor_position)
679    }
680
681    fn draw(
682        &self,
683        tree: &Tree,
684        renderer: &mut Renderer,
685        theme: &Renderer::Theme,
686        _style: &renderer::Style,
687        layout: Layout<'_>,
688        cursor_position: Point,
689        _viewport: &Rectangle,
690    ) {
691        draw(
692            renderer,
693            layout,
694            cursor_position,
695            tree.state.downcast_ref::<State<T>>(),
696            &self.value,
697            self.padding,
698            self.text_size,
699            &self.font,
700            self.placeholder.as_deref(),
701            self.selected.as_ref(),
702            &self.style_sheet,
703            &self.text_style_sheet,
704            theme,
705        )
706    }
707
708    fn overlay<'b>(
709        &'b self,
710        tree: &'b mut Tree,
711        layout: Layout<'_>,
712        _renderer: &Renderer,
713    ) -> Option<overlay::Element<'_, Message, Renderer>> {
714        let state = tree.state.downcast_mut::<State<T>>();
715        overlay(
716            layout,
717            state,
718            self.padding,
719            self.text_size,
720            self.font.clone(),
721            &self.options,
722            self.style_sheet.clone(),
723        )
724    }
725}
726
727impl<'a, T: 'static, Message, Renderer> From<PickList<'a, T, Message, Renderer>>
728    for Element<'a, Message, Renderer>
729where
730    T: Clone + ToString + Eq,
731    [T]: ToOwned<Owned = Vec<T>>,
732    Renderer: text::Renderer + 'a,
733    Message: 'static + Clone,
734    Renderer::Theme: StyleSheet
735        + scrollable::StyleSheet
736        + menu::StyleSheet
737        + container::StyleSheet
738        + text_input::StyleSheet,
739    <Renderer::Theme as menu::StyleSheet>::Style: From<<Renderer::Theme as StyleSheet>::Style>,
740{
741    fn from(val: PickList<'a, T, Message, Renderer>) -> Self {
742        Element::new(val)
743    }
744}