Skip to main content

egui/containers/
combo_box.rs

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