Skip to main content

iced_cube/forms/
combobox.rs

1//! A text field with a list of suggestions that filters as you type.
2//!
3//! [`State`] holds the options, the query, the highlighted suggestion and
4//! the selected value. The app owns it and passes every [`Event`] to
5//! [`State::update`], which returns the value when one is chosen.
6//!
7//! While the field has focus or the list is open, the combobox handles its
8//! own keys through a [`Keymap`] of [`Action`]s: arrows move the highlight,
9//! Enter chooses and Escape closes. See [`default_keymap`]. The list is
10//! drawn in the shared [menu look](crate::overlay::menu) on the
11//! [anchored layer](crate::overlay::anchored), so a click outside closes
12//! only the list, even inside a dialog or popover.
13//!
14//! ```no_run
15//! use iced::Element;
16//! use iced_cube::forms::combobox::{self, combobox};
17//!
18//! #[derive(Debug, Clone)]
19//! enum Message {
20//!     Fruit(combobox::Event),
21//! }
22//!
23//! struct App {
24//!     fruit: combobox::State<&'static str>,
25//! }
26//!
27//! impl App {
28//!     fn update(&mut self, message: Message) {
29//!         let Message::Fruit(event) = message;
30//!         if let Some(fruit) = self.fruit.update(event) {
31//!             println!("Chose {fruit}");
32//!         }
33//!     }
34//!
35//!     fn view(&self) -> Element<'_, Message> {
36//!         combobox(&self.fruit)
37//!             .placeholder("Search fruit...")
38//!             .on_event(Message::Fruit)
39//!             .into()
40//!     }
41//! }
42//! ```
43
44use std::fmt::{self, Display};
45use std::rc::Rc;
46
47use iced::advanced::layout::{self, Layout};
48use iced::advanced::widget::{Operation, Tree, Widget, operation, tree};
49use iced::advanced::{Clipboard, Shell, overlay, renderer};
50use iced::keyboard::key::Named;
51use iced::mouse::{self, ScrollDelta};
52use iced::widget::text::LineHeight;
53use iced::widget::text_input::{self, Status};
54use iced::widget::{self, Column, container, mouse_area, stack};
55use iced::{Alignment, Element, Length, Padding, Renderer, Theme};
56
57use crate::icon::{opacity, themed};
58use crate::keys::{self, Chord, Keymap};
59use crate::overlay::anchored::{Align, Placement, Side, anchored};
60use crate::overlay::menu::{self, Parts, RowStatus, Trailing};
61use crate::primitives::input::{self, Size};
62use crate::theme::{Tokens, space};
63
64/// How many suggestions show at once unless the state sets its own number.
65pub const VISIBLE_ROWS: usize = 6;
66/// Shown when no option matches the query, unless the builder sets its own.
67pub const DEFAULT_EMPTY: &str = "No results found.";
68
69// Matches the line height `input` uses, so the field is the same height.
70const LINE_HEIGHT: f32 = 1.3;
71
72/// Everything that changes a combobox.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Event {
75    /// The query was edited. Opens the list and highlights the first match.
76    Input(String),
77    Open,
78    /// Closes the list and clears the query.
79    Close,
80    /// Highlights the next match, wrapping round. Opens a closed list.
81    Next,
82    /// Highlights the previous match, wrapping round. Opens a closed list.
83    Previous,
84    /// Chooses the highlighted match.
85    ActivateHighlighted,
86    /// Highlights an option, by its index in [`State::options`].
87    Highlight(usize),
88    /// Chooses an option, by its index in [`State::options`].
89    Activate(usize),
90    /// Scrolls the list by a number of rows, down when positive.
91    Scroll(i32),
92}
93
94/// The options, the query and what is highlighted and selected.
95#[derive(Debug, Clone, PartialEq)]
96pub struct State<T> {
97    options: Vec<T>,
98    labels: Vec<String>,
99    query: String,
100    /// Indices into `options` of the options that match `query`.
101    matches: Vec<usize>,
102    /// Position in `matches`.
103    highlighted: usize,
104    /// Position in `matches` of the first visible row.
105    offset: usize,
106    selected: Option<usize>,
107    open: bool,
108    rows: usize,
109}
110
111impl<T: Display> State<T> {
112    /// Creates a closed combobox with nothing selected. Options are
113    /// labelled with their `Display` text.
114    pub fn new(options: impl IntoIterator<Item = T>) -> Self {
115        let options: Vec<T> = options.into_iter().collect();
116        let labels = options
117            .iter()
118            .map(|option| option.to_string().to_lowercase())
119            .collect();
120        let matches = (0..options.len()).collect();
121        Self {
122            options,
123            labels,
124            query: String::new(),
125            matches,
126            highlighted: 0,
127            offset: 0,
128            selected: None,
129            open: false,
130            rows: VISIBLE_ROWS,
131        }
132    }
133}
134
135impl<T: PartialEq> State<T> {
136    /// Starts with `value` selected, when it is one of the options.
137    pub fn with_selected(mut self, value: &T) -> Self {
138        self.selected = self.options.iter().position(|option| option == value);
139        self
140    }
141}
142
143impl<T> State<T> {
144    /// Sets how many suggestions show at once. At least one does.
145    pub fn with_visible_rows(mut self, rows: usize) -> Self {
146        self.rows = rows.max(1);
147        self.reveal();
148        self
149    }
150
151    pub fn options(&self) -> &[T] {
152        &self.options
153    }
154
155    pub fn selected(&self) -> Option<&T> {
156        self.selected.and_then(|index| self.options.get(index))
157    }
158
159    pub fn query(&self) -> &str {
160        &self.query
161    }
162
163    pub fn is_open(&self) -> bool {
164        self.open
165    }
166
167    /// The options that match the query, in their original order.
168    pub fn matches(&self) -> impl Iterator<Item = &T> {
169        self.matches
170            .iter()
171            .filter_map(|&index| self.options.get(index))
172    }
173
174    /// The highlighted option, if any option matches.
175    pub fn highlighted(&self) -> Option<&T> {
176        self.matches
177            .get(self.highlighted)
178            .and_then(|&index| self.options.get(index))
179    }
180
181    /// The matches in view, with their index in [`State::options`].
182    pub fn visible(&self) -> impl Iterator<Item = (usize, &T)> {
183        self.matches
184            .iter()
185            .skip(self.offset)
186            .take(self.rows)
187            .filter_map(|&index| self.options.get(index).map(|option| (index, option)))
188    }
189
190    fn is_highlighted(&self, index: usize) -> bool {
191        self.matches.get(self.highlighted) == Some(&index)
192    }
193
194    /// Applies an event and returns the chosen value, if one was chosen.
195    pub fn update(&mut self, event: Event) -> Option<T>
196    where
197        T: Clone,
198    {
199        match event {
200            Event::Input(query) => {
201                self.query = query;
202                self.open = true;
203                self.filter();
204                self.highlight(0);
205            }
206            Event::Open => self.open(),
207            Event::Close => self.close(),
208            Event::Next | Event::Previous if !self.open => self.open(),
209            Event::Next => self.step(1),
210            Event::Previous => self.step(-1),
211            Event::ActivateHighlighted => {
212                if !self.open {
213                    return None;
214                }
215                let index = *self.matches.get(self.highlighted)?;
216                return self.choose(index);
217            }
218            Event::Activate(index) => return self.choose(index),
219            Event::Highlight(index) => {
220                if let Some(position) = self.matches.iter().position(|&i| i == index) {
221                    self.highlight(position);
222                }
223            }
224            Event::Scroll(rows) => {
225                let last = self.matches.len().saturating_sub(self.rows);
226                let offset = (self.offset as i64).saturating_add(rows.into());
227                self.offset = offset.clamp(0, last as i64) as usize;
228            }
229        }
230        None
231    }
232
233    fn filter(&mut self) {
234        let query = self.query.trim().to_lowercase();
235        self.matches = self
236            .labels
237            .iter()
238            .enumerate()
239            .filter(|(_, label)| label.contains(&query))
240            .map(|(index, _)| index)
241            .collect();
242    }
243
244    fn open(&mut self) {
245        self.open = true;
246        self.query.clear();
247        self.filter();
248        let selected = self
249            .selected
250            .and_then(|index| self.matches.iter().position(|&i| i == index));
251        self.offset = 0;
252        self.highlight(selected.unwrap_or(0));
253    }
254
255    fn close(&mut self) {
256        self.open = false;
257        self.query.clear();
258        self.filter();
259        self.offset = 0;
260    }
261
262    fn choose(&mut self, index: usize) -> Option<T>
263    where
264        T: Clone,
265    {
266        let value = self.options.get(index)?.clone();
267        self.selected = Some(index);
268        self.close();
269        Some(value)
270    }
271
272    fn step(&mut self, delta: isize) {
273        let len = self.matches.len() as isize;
274        if len == 0 {
275            return;
276        }
277        let next = (self.highlighted as isize + delta).rem_euclid(len);
278        self.highlight(next as usize);
279    }
280
281    fn highlight(&mut self, position: usize) {
282        self.highlighted = position.min(self.matches.len().saturating_sub(1));
283        self.reveal();
284    }
285
286    /// Moves the window so the highlighted row is in view.
287    fn reveal(&mut self) {
288        if self.highlighted < self.offset {
289            self.offset = self.highlighted;
290        } else if self.highlighted >= self.offset + self.rows {
291            self.offset = self.highlighted + 1 - self.rows;
292        }
293        self.offset = self
294            .offset
295            .min(self.matches.len().saturating_sub(self.rows));
296    }
297}
298
299/// What a combobox keyboard shortcut does.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
301pub enum Action {
302    Next,
303    Previous,
304    Activate,
305    Close,
306}
307
308impl Action {
309    /// The [`Event`] this action sends to `state`, or `None` when it would
310    /// do nothing, so the key can reach the text field instead.
311    pub fn event<T>(self, state: &State<T>) -> Option<Event> {
312        match self {
313            Action::Next => Some(Event::Next),
314            Action::Previous => Some(Event::Previous),
315            Action::Activate => state.open.then_some(Event::ActivateHighlighted),
316            Action::Close => state.open.then_some(Event::Close),
317        }
318    }
319}
320
321impl keys::Action for Action {
322    const ALL: &'static [Self] = &[
323        Action::Next,
324        Action::Previous,
325        Action::Activate,
326        Action::Close,
327    ];
328
329    fn defaults() -> Keymap<Self> {
330        default_keymap()
331    }
332
333    fn name(self) -> &'static str {
334        match self {
335            Action::Next => "Next",
336            Action::Previous => "Previous",
337            Action::Activate => "Activate",
338            Action::Close => "Close",
339        }
340    }
341
342    fn description(self) -> &'static str {
343        match self {
344            Action::Next => "Highlights the next suggestion, opening the list if it is closed.",
345            Action::Previous => {
346                "Highlights the previous suggestion, opening the list if it is closed."
347            }
348            Action::Activate => "Chooses the highlighted suggestion and closes the list.",
349            Action::Close => "Closes the list and clears the query.",
350        }
351    }
352}
353
354/// The default combobox shortcuts:
355///
356/// | Keys | Action |
357/// | --- | --- |
358/// | `ArrowDown` | [`Action::Next`] |
359/// | `ArrowUp` | [`Action::Previous`] |
360/// | `Enter` | [`Action::Activate`] |
361/// | `Escape` | [`Action::Close`] |
362///
363/// The combobox handles these itself while its field has focus or its list
364/// is open, so the app does not need to subscribe to key presses. Pass a
365/// changed keymap with [`Combobox::keymap`].
366pub fn default_keymap() -> Keymap<Action> {
367    Keymap::new()
368        .bind(Chord::named(Named::ArrowDown), Action::Next)
369        .bind(Chord::named(Named::ArrowUp), Action::Previous)
370        .bind(Chord::named(Named::Enter), Action::Activate)
371        .bind(Chord::named(Named::Escape), Action::Close)
372}
373
374/// A combobox builder. Convert it into an [`Element`] to render.
375///
376/// A combobox without [`on_event`](Combobox::on_event) is rendered disabled.
377pub struct Combobox<'a, T, Message> {
378    state: &'a State<T>,
379    placeholder: String,
380    empty: String,
381    width: Length,
382    size: Size,
383    id: Option<widget::Id>,
384    invalid: bool,
385    keymap: Keymap<Action>,
386    on_event: Option<Box<dyn Fn(Event) -> Message + 'a>>,
387}
388
389/// Renders the field and, while it is open, the suggestions of `state`.
390pub fn combobox<'a, T, Message>(state: &'a State<T>) -> Combobox<'a, T, Message> {
391    Combobox {
392        state,
393        placeholder: String::new(),
394        empty: DEFAULT_EMPTY.to_owned(),
395        width: Length::Fixed(240.0),
396        size: Size::default(),
397        id: None,
398        invalid: false,
399        keymap: default_keymap(),
400        on_event: None,
401    }
402}
403
404impl<T: fmt::Debug, Message> fmt::Debug for Combobox<'_, T, Message> {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        f.debug_struct("Combobox")
407            .field("state", self.state)
408            .field("placeholder", &self.placeholder)
409            .field("empty", &self.empty)
410            .field("width", &self.width)
411            .field("size", &self.size)
412            .field("invalid", &self.invalid)
413            .field("enabled", &self.on_event.is_some())
414            .finish_non_exhaustive()
415    }
416}
417
418impl<'a, T, Message> Combobox<'a, T, Message> {
419    /// Text shown while the field is empty and nothing is selected.
420    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
421        self.placeholder = placeholder.into();
422        self
423    }
424
425    /// Text shown in the list when no option matches the query.
426    pub fn empty(mut self, empty: impl Into<String>) -> Self {
427        self.empty = empty.into();
428        self
429    }
430
431    /// Overrides the width. Defaults to 240 pixels.
432    pub fn width(mut self, width: impl Into<Length>) -> Self {
433        self.width = width.into();
434        self
435    }
436
437    /// The field's height, padding and text size, shared with
438    /// [`input`](fn@crate::primitives::input). Defaults to [`Size::Md`].
439    pub fn size(mut self, size: Size) -> Self {
440        self.size = size;
441        self
442    }
443
444    /// Sets the widget id of the text field, to focus it or find it in tests.
445    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
446        self.id = Some(id.into());
447        self
448    }
449
450    /// Draws the field with the destructive border, as
451    /// [`input`](fn@crate::primitives::input) does, for example when a required
452    /// value is missing.
453    pub fn invalid(mut self, invalid: bool) -> Self {
454        self.invalid = invalid;
455        self
456    }
457
458    /// Replaces the [`default_keymap`].
459    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
460        self.keymap = keymap;
461        self
462    }
463
464    /// Maps combobox events to your message. Without it the combobox is
465    /// disabled.
466    pub fn on_event(mut self, on_event: impl Fn(Event) -> Message + 'a) -> Self {
467        self.on_event = Some(Box::new(on_event));
468        self
469    }
470
471    pub fn is_enabled(&self) -> bool {
472        self.on_event.is_some()
473    }
474}
475
476impl<'a, T, Message> From<Combobox<'a, T, Message>> for Element<'a, Message>
477where
478    T: Display + 'a,
479    Message: Clone + 'a,
480{
481    fn from(combobox: Combobox<'a, T, Message>) -> Self {
482        let Combobox {
483            state,
484            placeholder,
485            empty,
486            width,
487            size,
488            id,
489            invalid,
490            keymap,
491            on_event,
492        } = combobox;
493        let selected = state.selected().map(ToString::to_string);
494
495        let Some(on_event) = on_event else {
496            let value = selected.unwrap_or_default();
497            return container(field(&placeholder, &value, size, id, None, false, invalid))
498                .width(width)
499                .into();
500        };
501        let on_event: Rc<dyn Fn(Event) -> Message + 'a> = Rc::from(on_event);
502
503        let open = state.open;
504        let (value, hint) = if open {
505            (state.query.clone(), selected.unwrap_or(placeholder))
506        } else {
507            (selected.unwrap_or_default(), placeholder)
508        };
509
510        let list = open.then(|| suggestions(state, &empty, &*on_event));
511        let on_anchor_press = (!open).then(|| on_event(Event::Open));
512        let on_dismiss = open.then(|| on_event(Event::Close));
513        let on_input: OnInput<'a, Message> = {
514            let on_event = Rc::clone(&on_event);
515            Box::new(move |query| on_event(Event::Input(query)))
516        };
517        let field = container(field(
518            &hint,
519            &value,
520            size,
521            id,
522            Some(on_input),
523            open,
524            invalid,
525        ))
526        .width(width);
527        let field = CaretToEnd {
528            content: field.into(),
529            watch: (open, state.selected),
530        };
531
532        anchored(Element::new(field))
533            .content(list)
534            .placement(Placement::new(Side::Bottom, Align::Start))
535            .match_width(true)
536            .dismiss_keys([])
537            .dismiss_on_blur(true)
538            .on_dismiss_maybe(on_dismiss)
539            .on_anchor_press_maybe(on_anchor_press)
540            .on_key(move |key| {
541                keymap
542                    .resolve_event(key)
543                    .and_then(|action| action.event(state))
544                    .map(&*on_event)
545            })
546            .into()
547    }
548}
549
550type OnInput<'a, Message> = Box<dyn Fn(String) -> Message + 'a>;
551
552fn field<'a, Message: Clone + 'a>(
553    placeholder: &str,
554    value: &str,
555    size: Size,
556    id: Option<widget::Id>,
557    on_input: Option<OnInput<'a, Message>>,
558    open: bool,
559    invalid: bool,
560) -> Element<'a, Message> {
561    let enabled = on_input.is_some();
562    let metrics = size.metrics();
563    let mut padding = metrics.padding(false);
564    padding.right += metrics.icon + space::SM;
565
566    let mut input = widget::text_input(placeholder, value)
567        .size(metrics.text)
568        .line_height(LineHeight::Relative(LINE_HEIGHT))
569        .padding(padding)
570        .width(Length::Fill)
571        .style(move |theme, status| style(&Tokens::of(theme), status, open, invalid));
572    if let Some(id) = id {
573        input = input.id(id);
574    }
575    if let Some(on_input) = on_input {
576        input = input.on_input(on_input);
577    }
578
579    let chevron = themed(
580        crate::lucide!(ChevronsUpDown),
581        metrics.icon,
582        opacity(enabled),
583        |theme| Tokens::of(theme).muted_foreground,
584    );
585
586    stack![
587        input,
588        container(chevron)
589            .width(Length::Fill)
590            .height(Length::Fill)
591            .align_x(Alignment::End)
592            .align_y(Alignment::Center)
593            .padding(Padding::ZERO.right(metrics.padding_x)),
594    ]
595    .into()
596}
597
598fn suggestions<'a, T: Display, Message: Clone + 'a>(
599    state: &'a State<T>,
600    empty: &str,
601    on_event: &dyn Fn(Event) -> Message,
602) -> Element<'a, Message> {
603    let rows: Vec<Element<'a, Message>> = state
604        .visible()
605        .map(|(index, option)| {
606            let status = if state.is_highlighted(index) {
607                RowStatus::Highlighted
608            } else {
609                RowStatus::Idle
610            };
611            let label = option.to_string();
612            let trailing = if state.selected == Some(index) {
613                Trailing::Check
614            } else {
615                Trailing::None
616            };
617            let parts = Parts {
618                trailing,
619                ..Parts::label(&label)
620            };
621            let messages = (
622                on_event(Event::Highlight(index)),
623                on_event(Event::Activate(index)),
624            );
625            menu::item(parts, status, false, Some(messages))
626        })
627        .collect();
628
629    let body: Element<'a, Message> = if rows.is_empty() {
630        menu::empty(empty)
631    } else {
632        Column::from_vec(rows).width(Length::Fill).into()
633    };
634
635    let down = on_event(Event::Scroll(1));
636    let up = on_event(Event::Scroll(-1));
637    mouse_area(menu::surface(body).width(Length::Fill))
638        .on_scroll(move |delta| match delta {
639            ScrollDelta::Lines { y, .. } | ScrollDelta::Pixels { y, .. } if y < 0.0 => down.clone(),
640            _ => up.clone(),
641        })
642        .into()
643}
644
645/// The field style: the input style, drawn focused while the list is open
646/// and with the destructive border while `invalid`.
647pub fn style(tokens: &Tokens, status: Status, open: bool, invalid: bool) -> text_input::Style {
648    let status = match status {
649        Status::Active if open => Status::Focused { is_hovered: false },
650        Status::Hovered if open => Status::Focused { is_hovered: true },
651        status => status,
652    };
653    input::style(tokens, status, invalid)
654}
655
656/// Wraps the field and moves its caret to the end whenever the list closes
657/// or the selection changes, so a picked value reads from its end rather
658/// than from wherever the caret was in the query.
659struct CaretToEnd<'a, Message> {
660    content: Element<'a, Message>,
661    /// Whether the list is open, and the selected option.
662    watch: (bool, Option<usize>),
663}
664
665/// The caret goes to the end once the list is closed, if it just closed or
666/// the selection changed.
667fn moves_caret(before: Option<(bool, Option<usize>)>, now: (bool, Option<usize>)) -> bool {
668    let Some(before) = before else {
669        return false;
670    };
671    let (open, selected) = now;
672    !open && (before.0 || before.1 != selected)
673}
674
675struct MoveCaretToEnd;
676
677impl Operation for MoveCaretToEnd {
678    fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) {
679        operate(self);
680    }
681
682    fn text_input(
683        &mut self,
684        _id: Option<&widget::Id>,
685        _bounds: iced::Rectangle,
686        state: &mut dyn operation::TextInput,
687    ) {
688        state.move_cursor_to_end();
689    }
690}
691
692impl<Message> Widget<Message, Theme, Renderer> for CaretToEnd<'_, Message> {
693    fn size(&self) -> iced::Size<Length> {
694        self.content.as_widget().size()
695    }
696
697    fn size_hint(&self) -> iced::Size<Length> {
698        self.content.as_widget().size_hint()
699    }
700
701    fn tag(&self) -> tree::Tag {
702        tree::Tag::of::<Option<(bool, Option<usize>)>>()
703    }
704
705    fn state(&self) -> tree::State {
706        tree::State::new(None::<(bool, Option<usize>)>)
707    }
708
709    fn children(&self) -> Vec<Tree> {
710        vec![Tree::new(&self.content)]
711    }
712
713    fn diff(&self, tree: &mut Tree) {
714        tree.diff_children(std::slice::from_ref(&self.content));
715    }
716
717    // Layout runs after every change to the view, before the next draw, so
718    // the caret moves without waiting for another event.
719    fn layout(
720        &mut self,
721        tree: &mut Tree,
722        renderer: &Renderer,
723        limits: &layout::Limits,
724    ) -> layout::Node {
725        let node = self
726            .content
727            .as_widget_mut()
728            .layout(&mut tree.children[0], renderer, limits);
729        let seen = tree.state.downcast_mut::<Option<(bool, Option<usize>)>>();
730        let before = seen.replace(self.watch);
731        if moves_caret(before, self.watch) {
732            self.content.as_widget_mut().operate(
733                &mut tree.children[0],
734                Layout::new(&node),
735                renderer,
736                &mut MoveCaretToEnd,
737            );
738        }
739        node
740    }
741
742    fn draw(
743        &self,
744        tree: &Tree,
745        renderer: &mut Renderer,
746        theme: &Theme,
747        style: &renderer::Style,
748        layout: Layout<'_>,
749        cursor: mouse::Cursor,
750        viewport: &iced::Rectangle,
751    ) {
752        self.content.as_widget().draw(
753            &tree.children[0],
754            renderer,
755            theme,
756            style,
757            layout,
758            cursor,
759            viewport,
760        );
761    }
762
763    fn operate(
764        &mut self,
765        tree: &mut Tree,
766        layout: Layout<'_>,
767        renderer: &Renderer,
768        operation: &mut dyn Operation,
769    ) {
770        self.content
771            .as_widget_mut()
772            .operate(&mut tree.children[0], layout, renderer, operation);
773    }
774
775    fn update(
776        &mut self,
777        tree: &mut Tree,
778        event: &iced::Event,
779        layout: Layout<'_>,
780        cursor: mouse::Cursor,
781        renderer: &Renderer,
782        clipboard: &mut dyn Clipboard,
783        shell: &mut Shell<'_, Message>,
784        viewport: &iced::Rectangle,
785    ) {
786        self.content.as_widget_mut().update(
787            &mut tree.children[0],
788            event,
789            layout,
790            cursor,
791            renderer,
792            clipboard,
793            shell,
794            viewport,
795        );
796    }
797
798    fn mouse_interaction(
799        &self,
800        tree: &Tree,
801        layout: Layout<'_>,
802        cursor: mouse::Cursor,
803        viewport: &iced::Rectangle,
804        renderer: &Renderer,
805    ) -> mouse::Interaction {
806        self.content.as_widget().mouse_interaction(
807            &tree.children[0],
808            layout,
809            cursor,
810            viewport,
811            renderer,
812        )
813    }
814
815    fn overlay<'b>(
816        &'b mut self,
817        tree: &'b mut Tree,
818        layout: Layout<'b>,
819        renderer: &Renderer,
820        viewport: &iced::Rectangle,
821        translation: iced::Vector,
822    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
823        self.content.as_widget_mut().overlay(
824            &mut tree.children[0],
825            layout,
826            renderer,
827            viewport,
828            translation,
829        )
830    }
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836    use crate::theme::{dark, light};
837
838    const FRUITS: [&str; 5] = ["Apple", "Banana", "Blueberry", "Cherry", "Grape"];
839
840    fn fruits() -> State<&'static str> {
841        State::new(FRUITS)
842    }
843
844    fn matches(state: &State<&'static str>) -> Vec<&'static str> {
845        state.matches().copied().collect()
846    }
847
848    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
849        let chord: Chord = chord.parse().unwrap();
850        keymap.resolve(chord.key(), chord.modifiers())
851    }
852
853    #[test]
854    fn new_state_is_closed_with_every_option() {
855        let state = fruits();
856        assert!(!state.is_open());
857        assert!(state.selected().is_none());
858        assert_eq!(state.query(), "");
859        assert_eq!(matches(&state), FRUITS);
860        assert_eq!(state.visible().count(), 5);
861    }
862
863    #[test]
864    fn with_selected_ignores_unknown_values() {
865        assert_eq!(
866            fruits().with_selected(&"Cherry").selected(),
867            Some(&"Cherry")
868        );
869        assert!(fruits().with_selected(&"Kiwi").selected().is_none());
870    }
871
872    #[test]
873    fn typing_filters_case_insensitively_and_keeps_order() {
874        let mut state = fruits();
875        assert!(state.update(Event::Input("B".into())).is_none());
876        assert!(state.is_open());
877        assert_eq!(matches(&state), ["Banana", "Blueberry"]);
878        assert_eq!(state.highlighted(), Some(&"Banana"));
879
880        let _ = state.update(Event::Input("  ERR ".into()));
881        assert_eq!(matches(&state), ["Blueberry", "Cherry"]);
882    }
883
884    #[test]
885    fn no_match_leaves_nothing_highlighted() {
886        let mut state = fruits();
887        let _ = state.update(Event::Input("kiwi".into()));
888        assert!(state.matches().next().is_none());
889        assert!(state.highlighted().is_none());
890        assert!(state.update(Event::ActivateHighlighted).is_none());
891        let _ = state.update(Event::Next);
892        assert!(state.highlighted().is_none());
893    }
894
895    #[test]
896    fn arrows_open_then_wrap_round() {
897        let mut state = fruits();
898        let _ = state.update(Event::Next);
899        assert!(state.is_open());
900        assert_eq!(state.highlighted(), Some(&"Apple"));
901
902        let _ = state.update(Event::Previous);
903        assert_eq!(state.highlighted(), Some(&"Grape"));
904        let _ = state.update(Event::Next);
905        assert_eq!(state.highlighted(), Some(&"Apple"));
906    }
907
908    #[test]
909    fn opening_highlights_the_selection() {
910        let mut state = fruits().with_selected(&"Cherry");
911        let _ = state.update(Event::Open);
912        assert_eq!(state.highlighted(), Some(&"Cherry"));
913    }
914
915    #[test]
916    fn confirm_chooses_closes_and_clears_the_query() {
917        let mut state = fruits();
918        let _ = state.update(Event::Input("an".into()));
919        assert_eq!(state.update(Event::ActivateHighlighted), Some("Banana"));
920        assert!(!state.is_open());
921        assert_eq!(state.query(), "");
922        assert_eq!(state.selected(), Some(&"Banana"));
923        assert_eq!(matches(&state).len(), FRUITS.len());
924    }
925
926    #[test]
927    fn confirm_does_nothing_while_closed() {
928        let mut state = fruits();
929        assert!(state.update(Event::ActivateHighlighted).is_none());
930        assert!(state.selected().is_none());
931    }
932
933    #[test]
934    fn pick_and_highlight_use_option_indices() {
935        let mut state = fruits();
936        let _ = state.update(Event::Input("berry".into()));
937        let _ = state.update(Event::Highlight(2));
938        assert_eq!(state.highlighted(), Some(&"Blueberry"));
939        // Highlighting an option that does not match is ignored.
940        let _ = state.update(Event::Highlight(0));
941        assert_eq!(state.highlighted(), Some(&"Blueberry"));
942
943        assert_eq!(state.update(Event::Activate(3)), Some("Cherry"));
944        assert!(state.update(Event::Activate(99)).is_none());
945        assert_eq!(state.selected(), Some(&"Cherry"));
946    }
947
948    #[test]
949    fn close_keeps_the_selection() {
950        let mut state = fruits().with_selected(&"Apple");
951        let _ = state.update(Event::Input("gr".into()));
952        let _ = state.update(Event::Close);
953        assert!(!state.is_open());
954        assert_eq!(state.query(), "");
955        assert_eq!(state.selected(), Some(&"Apple"));
956    }
957
958    #[test]
959    fn the_window_follows_the_highlight() {
960        let mut state = fruits().with_visible_rows(2);
961        let _ = state.update(Event::Open);
962        let visible = |state: &State<&'static str>| -> Vec<&str> {
963            state.visible().map(|(_, fruit)| *fruit).collect()
964        };
965        assert_eq!(visible(&state), ["Apple", "Banana"]);
966
967        let _ = state.update(Event::Next);
968        let _ = state.update(Event::Next);
969        assert_eq!(state.highlighted(), Some(&"Blueberry"));
970        assert_eq!(visible(&state), ["Banana", "Blueberry"]);
971
972        let _ = state.update(Event::Previous);
973        let _ = state.update(Event::Previous);
974        let _ = state.update(Event::Previous);
975        assert_eq!(state.highlighted(), Some(&"Grape"));
976        assert_eq!(visible(&state), ["Cherry", "Grape"]);
977    }
978
979    #[test]
980    fn scrolling_stays_within_the_matches() {
981        let mut state = fruits().with_visible_rows(2);
982        let _ = state.update(Event::Open);
983        let _ = state.update(Event::Scroll(10));
984        assert_eq!(state.visible().map(|(i, _)| i).collect::<Vec<_>>(), [3, 4]);
985        let _ = state.update(Event::Scroll(-10));
986        assert_eq!(state.visible().map(|(i, _)| i).collect::<Vec<_>>(), [0, 1]);
987        assert_eq!(State::<&str>::new([]).with_visible_rows(0).rows, 1);
988    }
989
990    #[test]
991    fn empty_options_are_safe() {
992        let mut state = State::<&str>::new([]);
993        let _ = state.update(Event::Next);
994        let _ = state.update(Event::Previous);
995        let _ = state.update(Event::Scroll(3));
996        assert!(state.update(Event::ActivateHighlighted).is_none());
997        assert!(state.highlighted().is_none());
998    }
999
1000    #[test]
1001    fn default_keymap_and_overrides() {
1002        let keymap = default_keymap();
1003        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1004        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1005        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1006        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1007
1008        let custom = keymap
1009            .bind("Ctrl+N".parse().unwrap(), Action::Next)
1010            .unbind(&Chord::named(Named::Escape));
1011        assert_eq!(press(&custom, "ctrl+n"), Some(Action::Next));
1012        assert_eq!(press(&custom, "Escape"), None);
1013    }
1014
1015    #[test]
1016    fn actions_map_to_events_only_when_they_apply() {
1017        let mut state = fruits();
1018        assert_eq!(Action::Next.event(&state), Some(Event::Next));
1019        assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
1020        assert_eq!(Action::Activate.event(&state), None);
1021        assert_eq!(Action::Close.event(&state), None);
1022
1023        let _ = state.update(Event::Open);
1024        assert_eq!(
1025            Action::Activate.event(&state),
1026            Some(Event::ActivateHighlighted)
1027        );
1028        assert_eq!(Action::Close.event(&state), Some(Event::Close));
1029    }
1030
1031    #[test]
1032    fn default_builder_is_disabled_with_the_default_empty_text() {
1033        let state = fruits();
1034        let c: Combobox<'_, &str, Event> = combobox(&state);
1035        assert!(!c.is_enabled());
1036        assert_eq!(c.empty, DEFAULT_EMPTY);
1037        assert_eq!(c.width, Length::Fixed(240.0));
1038        assert_eq!(c.size, Size::Md);
1039        assert_eq!(c.keymap, default_keymap());
1040        assert!(!c.invalid);
1041        let c = c
1042            .placeholder("Pick")
1043            .empty("None")
1044            .size(Size::Sm)
1045            .invalid(true)
1046            .on_event(|e| e);
1047        assert_eq!(c.size, Size::Sm);
1048        assert!(c.invalid);
1049        assert!(c.is_enabled());
1050        assert_eq!((c.placeholder.as_str(), c.empty.as_str()), ("Pick", "None"));
1051    }
1052
1053    const STATES: [Status; 5] = [
1054        Status::Active,
1055        Status::Hovered,
1056        Status::Focused { is_hovered: false },
1057        Status::Focused { is_hovered: true },
1058        Status::Disabled,
1059    ];
1060
1061    #[test]
1062    fn open_field_draws_the_focused_border() {
1063        for theme in [light(), dark()] {
1064            let tokens = Tokens::of(&theme);
1065            let focused = input::style(&tokens, Status::Focused { is_hovered: false }, false);
1066            for status in STATES {
1067                let closed = style(&tokens, status, false, false);
1068                assert_eq!(closed, input::style(&tokens, status, false), "{status:?}");
1069                let open = style(&tokens, status, true, false);
1070                if status != Status::Disabled {
1071                    assert_eq!(open.border.color, focused.border.color, "{status:?}");
1072                }
1073            }
1074        }
1075    }
1076
1077    #[test]
1078    fn disabled_field_uses_the_shared_disabled_background() {
1079        for theme in [light(), dark()] {
1080            let tokens = Tokens::of(&theme);
1081            for open in [false, true] {
1082                let disabled = style(&tokens, Status::Disabled, open, false);
1083                assert_eq!(
1084                    disabled.background,
1085                    iced::Background::Color(tokens.disabled_field())
1086                );
1087            }
1088        }
1089    }
1090
1091    #[test]
1092    fn invalid_field_uses_the_input_invalid_style() {
1093        for theme in [light(), dark()] {
1094            let tokens = Tokens::of(&theme);
1095            for status in STATES {
1096                assert_eq!(
1097                    style(&tokens, status, false, true),
1098                    input::style(&tokens, status, true),
1099                    "{status:?}"
1100                );
1101                let valid = style(&tokens, status, false, false);
1102                if status != Status::Disabled {
1103                    assert_ne!(
1104                        style(&tokens, status, false, true).border.color,
1105                        valid.border.color,
1106                        "{status:?}"
1107                    );
1108                }
1109            }
1110        }
1111    }
1112
1113    #[test]
1114    fn the_caret_moves_to_the_end_when_the_list_closes_or_the_pick_changes() {
1115        assert!(
1116            !moves_caret(None, (false, None)),
1117            "nothing to move on first layout"
1118        );
1119        assert!(moves_caret(Some((true, None)), (false, Some(2))), "a pick");
1120        assert!(
1121            moves_caret(Some((true, Some(2))), (false, Some(2))),
1122            "Escape"
1123        );
1124        assert!(
1125            moves_caret(Some((false, Some(1))), (false, Some(2))),
1126            "set by the app"
1127        );
1128        assert!(!moves_caret(Some((false, Some(2))), (false, Some(2))));
1129        assert!(!moves_caret(Some((false, None)), (true, None)), "opening");
1130        assert!(!moves_caret(Some((true, None)), (true, None)), "typing");
1131    }
1132}