egui/containers/
combo_box.rs

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