egui/containers/
combo_box.rs

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