egui/containers/
combo_box.rs

1use epaint::Shape;
2
3use crate::{
4    epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter,
5    PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui,
6    UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
7};
8
9#[allow(unused_imports)] // Documentation
10use crate::style::Spacing;
11
12/// Indicate whether a popup will be shown above or below the box.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum AboveOrBelow {
15    Above,
16    Below,
17}
18
19/// A function that paints the [`ComboBox`] icon
20pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
21
22/// A drop-down selection menu with a descriptive label.
23///
24/// ```
25/// # egui::__run_test_ui(|ui| {
26/// # #[derive(Debug, PartialEq)]
27/// # enum Enum { First, Second, Third }
28/// # let mut selected = Enum::First;
29/// egui::ComboBox::from_label("Select one!")
30///     .selected_text(format!("{:?}", selected))
31///     .show_ui(ui, |ui| {
32///         ui.selectable_value(&mut selected, Enum::First, "First");
33///         ui.selectable_value(&mut selected, Enum::Second, "Second");
34///         ui.selectable_value(&mut selected, Enum::Third, "Third");
35///     }
36/// );
37/// # });
38/// ```
39#[must_use = "You should call .show*"]
40pub struct ComboBox {
41    id_salt: Id,
42    label: Option<WidgetText>,
43    selected_text: WidgetText,
44    width: Option<f32>,
45    height: Option<f32>,
46    icon: Option<IconPainter>,
47    wrap_mode: Option<TextWrapMode>,
48    close_behavior: Option<PopupCloseBehavior>,
49}
50
51impl ComboBox {
52    /// Create new [`ComboBox`] with id and label
53    pub fn new(id_salt: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
54        Self {
55            id_salt: Id::new(id_salt),
56            label: Some(label.into()),
57            selected_text: Default::default(),
58            width: None,
59            height: None,
60            icon: None,
61            wrap_mode: None,
62            close_behavior: None,
63        }
64    }
65
66    /// Label shown next to the combo box
67    pub fn from_label(label: impl Into<WidgetText>) -> Self {
68        let label = label.into();
69        Self {
70            id_salt: Id::new(label.text()),
71            label: Some(label),
72            selected_text: Default::default(),
73            width: None,
74            height: None,
75            icon: None,
76            wrap_mode: None,
77            close_behavior: None,
78        }
79    }
80
81    /// Without label.
82    pub fn from_id_salt(id_salt: impl std::hash::Hash) -> Self {
83        Self {
84            id_salt: Id::new(id_salt),
85            label: Default::default(),
86            selected_text: Default::default(),
87            width: None,
88            height: None,
89            icon: None,
90            wrap_mode: None,
91            close_behavior: None,
92        }
93    }
94
95    /// Without label.
96    #[deprecated = "Renamed id_salt"]
97    pub fn from_id_source(id_salt: impl std::hash::Hash) -> Self {
98        Self::from_id_salt(id_salt)
99    }
100
101    /// Set the outer width of the button and menu.
102    ///
103    /// Default is [`Spacing::combo_width`].
104    #[inline]
105    pub fn width(mut self, width: f32) -> Self {
106        self.width = Some(width);
107        self
108    }
109
110    /// Set the maximum outer height of the menu.
111    ///
112    /// Default is [`Spacing::combo_height`].
113    #[inline]
114    pub fn height(mut self, height: f32) -> Self {
115        self.height = Some(height);
116        self
117    }
118
119    /// What we show as the currently selected value
120    #[inline]
121    pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
122        self.selected_text = selected_text.into();
123        self
124    }
125
126    /// Use the provided function to render a different [`ComboBox`] icon.
127    /// Defaults to a triangle that expands when the cursor is hovering over the [`ComboBox`].
128    ///
129    /// For example:
130    /// ```
131    /// # egui::__run_test_ui(|ui| {
132    /// # let text = "Selected text";
133    /// pub fn filled_triangle(
134    ///     ui: &egui::Ui,
135    ///     rect: egui::Rect,
136    ///     visuals: &egui::style::WidgetVisuals,
137    ///     _is_open: bool,
138    ///     _above_or_below: egui::AboveOrBelow,
139    /// ) {
140    ///     let rect = egui::Rect::from_center_size(
141    ///         rect.center(),
142    ///         egui::vec2(rect.width() * 0.6, rect.height() * 0.4),
143    ///     );
144    ///     ui.painter().add(egui::Shape::convex_polygon(
145    ///         vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
146    ///         visuals.fg_stroke.color,
147    ///         visuals.fg_stroke,
148    ///     ));
149    /// }
150    ///
151    /// egui::ComboBox::from_id_salt("my-combobox")
152    ///     .selected_text(text)
153    ///     .icon(filled_triangle)
154    ///     .show_ui(ui, |_ui| {});
155    /// # });
156    /// ```
157    pub fn icon(
158        mut self,
159        icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
160    ) -> Self {
161        self.icon = Some(Box::new(icon_fn));
162        self
163    }
164
165    /// Controls the wrap mode used for the selected text.
166    ///
167    /// By default, [`Ui::wrap_mode`] will be used, which can be overridden with [`crate::Style::wrap_mode`].
168    ///
169    /// Note that any `\n` in the text will always produce a new line.
170    #[inline]
171    pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
172        self.wrap_mode = Some(wrap_mode);
173        self
174    }
175
176    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
177    #[inline]
178    pub fn wrap(mut self) -> Self {
179        self.wrap_mode = Some(TextWrapMode::Wrap);
180        self
181    }
182
183    /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
184    #[inline]
185    pub fn truncate(mut self) -> Self {
186        self.wrap_mode = Some(TextWrapMode::Truncate);
187        self
188    }
189
190    /// Controls the close behavior for the popup.
191    ///
192    /// By default, `PopupCloseBehavior::CloseOnClick` will be used.
193    #[inline]
194    pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
195        self.close_behavior = Some(close_behavior);
196        self
197    }
198
199    /// Show the combo box, with the given ui code for the menu contents.
200    ///
201    /// Returns `InnerResponse { inner: None }` if the combo box is closed.
202    pub fn show_ui<R>(
203        self,
204        ui: &mut Ui,
205        menu_contents: impl FnOnce(&mut Ui) -> R,
206    ) -> InnerResponse<Option<R>> {
207        self.show_ui_dyn(ui, Box::new(menu_contents))
208    }
209
210    fn show_ui_dyn<'c, R>(
211        self,
212        ui: &mut Ui,
213        menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
214    ) -> InnerResponse<Option<R>> {
215        let Self {
216            id_salt,
217            label,
218            selected_text,
219            width,
220            height,
221            icon,
222            wrap_mode,
223            close_behavior,
224        } = self;
225
226        let button_id = ui.make_persistent_id(id_salt);
227
228        ui.horizontal(|ui| {
229            let mut ir = combo_box_dyn(
230                ui,
231                button_id,
232                selected_text,
233                menu_contents,
234                icon,
235                wrap_mode,
236                close_behavior,
237                (width, height),
238            );
239            if let Some(label) = label {
240                ir.response.widget_info(|| {
241                    WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text())
242                });
243                ir.response |= ui.label(label);
244            } else {
245                ir.response
246                    .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), ""));
247            }
248            ir
249        })
250        .inner
251    }
252
253    /// Show a list of items with the given selected index.
254    ///
255    ///
256    /// ```
257    /// # #[derive(Debug, PartialEq)]
258    /// # enum Enum { First, Second, Third }
259    /// # let mut selected = Enum::First;
260    /// # egui::__run_test_ui(|ui| {
261    /// let alternatives = ["a", "b", "c", "d"];
262    /// let mut selected = 2;
263    /// egui::ComboBox::from_label("Select one!").show_index(
264    ///     ui,
265    ///     &mut selected,
266    ///     alternatives.len(),
267    ///     |i| alternatives[i]
268    /// );
269    /// # });
270    /// ```
271    pub fn show_index<Text: Into<WidgetText>>(
272        self,
273        ui: &mut Ui,
274        selected: &mut usize,
275        len: usize,
276        get: impl Fn(usize) -> Text,
277    ) -> Response {
278        let slf = self.selected_text(get(*selected));
279
280        let mut changed = false;
281
282        let mut response = slf
283            .show_ui(ui, |ui| {
284                for i in 0..len {
285                    if ui.selectable_label(i == *selected, get(i)).clicked() {
286                        *selected = i;
287                        changed = true;
288                    }
289                }
290            })
291            .response;
292
293        if changed {
294            response.mark_changed();
295        }
296        response
297    }
298
299    /// Check if the [`ComboBox`] with the given id has its popup menu currently opened.
300    pub fn is_open(ctx: &Context, id: Id) -> bool {
301        ctx.memory(|m| m.is_popup_open(Self::widget_to_popup_id(id)))
302    }
303
304    /// Convert a [`ComboBox`] id to the id used to store it's popup state.
305    fn widget_to_popup_id(widget_id: Id) -> Id {
306        widget_id.with("popup")
307    }
308}
309
310#[allow(clippy::too_many_arguments)]
311fn combo_box_dyn<'c, R>(
312    ui: &mut Ui,
313    button_id: Id,
314    selected_text: WidgetText,
315    menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
316    icon: Option<IconPainter>,
317    wrap_mode: Option<TextWrapMode>,
318    close_behavior: Option<PopupCloseBehavior>,
319    (width, height): (Option<f32>, Option<f32>),
320) -> InnerResponse<Option<R>> {
321    let popup_id = ComboBox::widget_to_popup_id(button_id);
322
323    let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
324
325    let popup_height = ui.memory(|m| {
326        m.areas()
327            .get(popup_id)
328            .and_then(|state| state.size)
329            .map_or(100.0, |size| size.y)
330    });
331
332    let above_or_below =
333        if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
334            < ui.ctx().screen_rect().bottom()
335        {
336            AboveOrBelow::Below
337        } else {
338            AboveOrBelow::Above
339        };
340
341    let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
342
343    let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
344
345    let margin = ui.spacing().button_padding;
346    let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
347        let icon_spacing = ui.spacing().icon_spacing;
348        let icon_size = Vec2::splat(ui.spacing().icon_width);
349
350        // The combo box selected text will always have this minimum width.
351        // Note: the `ComboBox::width()` if set or `Spacing::combo_width` are considered as the
352        // minimum overall width, regardless of the wrap mode.
353        let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
354
355        // width against which to lay out the selected text
356        let wrap_width = if wrap_mode == TextWrapMode::Extend {
357            // Use all the width necessary to display the currently selected value's text.
358            f32::INFINITY
359        } else {
360            // Use the available width, currently selected value's text will be wrapped if exceeds this value.
361            ui.available_width() - icon_spacing - icon_size.x
362        };
363
364        let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
365
366        let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
367        let actual_height = galley.size().y.max(icon_size.y);
368
369        let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
370        let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
371        let response = ui.interact(button_rect, button_id, Sense::click());
372        // response.active |= is_popup_open;
373
374        if ui.is_rect_visible(rect) {
375            let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
376            let visuals = if is_popup_open {
377                &ui.visuals().widgets.open
378            } else {
379                ui.style().interact(&response)
380            };
381
382            if let Some(icon) = icon {
383                icon(
384                    ui,
385                    icon_rect.expand(visuals.expansion),
386                    visuals,
387                    is_popup_open,
388                    above_or_below,
389                );
390            } else {
391                paint_default_icon(
392                    ui.painter(),
393                    icon_rect.expand(visuals.expansion),
394                    visuals,
395                    above_or_below,
396                );
397            }
398
399            let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
400            ui.painter()
401                .galley(text_rect.min, galley, visuals.text_color());
402        }
403    });
404
405    if button_response.clicked() {
406        ui.memory_mut(|mem| mem.toggle_popup(popup_id));
407    }
408
409    let height = height.unwrap_or_else(|| ui.spacing().combo_height);
410
411    let inner = crate::popup::popup_above_or_below_widget(
412        ui,
413        popup_id,
414        &button_response,
415        above_or_below,
416        close_behavior,
417        |ui| {
418            ScrollArea::vertical()
419                .max_height(height)
420                .show(ui, |ui| {
421                    // Often the button is very narrow, which means this popup
422                    // is also very narrow. Having wrapping on would therefore
423                    // result in labels that wrap very early.
424                    // Instead, we turn it off by default so that the labels
425                    // expand the width of the menu.
426                    ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
427                    menu_contents(ui)
428                })
429                .inner
430        },
431    );
432
433    InnerResponse {
434        inner,
435        response: button_response,
436    }
437}
438
439fn button_frame(
440    ui: &mut Ui,
441    id: Id,
442    is_popup_open: bool,
443    sense: Sense,
444    add_contents: impl FnOnce(&mut Ui),
445) -> Response {
446    let where_to_put_background = ui.painter().add(Shape::Noop);
447
448    let margin = ui.spacing().button_padding;
449    let interact_size = ui.spacing().interact_size;
450
451    let mut outer_rect = ui.available_rect_before_wrap();
452    outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
453
454    let inner_rect = outer_rect.shrink2(margin);
455    let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect));
456    add_contents(&mut content_ui);
457
458    let mut outer_rect = content_ui.min_rect().expand2(margin);
459    outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
460
461    let response = ui.interact(outer_rect, id, sense);
462
463    if ui.is_rect_visible(outer_rect) {
464        let visuals = if is_popup_open {
465            &ui.visuals().widgets.open
466        } else {
467            ui.style().interact(&response)
468        };
469
470        ui.painter().set(
471            where_to_put_background,
472            epaint::RectShape::new(
473                outer_rect.expand(visuals.expansion),
474                visuals.corner_radius,
475                visuals.weak_bg_fill,
476                visuals.bg_stroke,
477                epaint::StrokeKind::Inside,
478            ),
479        );
480    }
481
482    ui.advance_cursor_after_rect(outer_rect);
483
484    response
485}
486
487fn paint_default_icon(
488    painter: &Painter,
489    rect: Rect,
490    visuals: &WidgetVisuals,
491    above_or_below: AboveOrBelow,
492) {
493    let rect = Rect::from_center_size(
494        rect.center(),
495        vec2(rect.width() * 0.7, rect.height() * 0.45),
496    );
497
498    match above_or_below {
499        AboveOrBelow::Above => {
500            // Upward pointing triangle
501            painter.add(Shape::convex_polygon(
502                vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
503                visuals.fg_stroke.color,
504                Stroke::NONE,
505            ));
506        }
507        AboveOrBelow::Below => {
508            // Downward pointing triangle
509            painter.add(Shape::convex_polygon(
510                vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
511                visuals.fg_stroke.color,
512                Stroke::NONE,
513            ));
514        }
515    }
516}