Skip to main content

egui_elements/widgets/
combo_box.rs

1use super::label::Label;
2use crate::theme::Theme;
3use crate::visuals::ComboBoxVisuals;
4use egui::{
5   Align2, Id, InnerResponse, NumExt, Painter, Popup, PopupCloseBehavior, PopupKind, Rect,
6   Response, ScrollArea, Sense, Stroke, TextWrapMode, Ui, Vec2, WidgetText,
7   epaint::{RectShape, Shape, StrokeKind},
8   style::WidgetVisuals,
9};
10
11use std::{fmt::Debug, hash::Hash};
12
13/// Dropdown whose closed face is a [`Label`] (text and/or image).
14///
15/// ```
16/// # use egui::__run_test_ui;
17/// # use egui_elements::widgets::{ComboBox, Label};
18/// # __run_test_ui(|ui| {
19/// let current = Label::new("Tokyo Night", None);
20/// ComboBox::new("theme", current).width(200.0).show_ui(ui, |ui| {
21///     ui.label("Tokyo Night Light");
22/// });
23/// # });
24/// ```
25#[must_use = "You should call .show_ui()"]
26pub struct ComboBox {
27   id_salt: Id,
28   visuals: Option<ComboBoxVisuals>,
29   label: Option<WidgetText>,
30   selected_item: Label,
31   width: Option<f32>,
32   popup_max_height: Option<f32>,
33   icon: Option<Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>>,
34   wrap_mode: Option<TextWrapMode>,
35   close_behavior: Option<PopupCloseBehavior>,
36}
37
38impl ComboBox {
39   /// Create a combo box keyed by `id_salt`, showing `selected_item` on the button.
40   pub fn new(id_salt: impl Hash + Debug, selected_item: Label) -> Self {
41      Self {
42         id_salt: Id::new(id_salt),
43         visuals: None,
44         label: None,
45         selected_item,
46         width: None,
47         popup_max_height: None,
48         icon: None,
49         wrap_mode: None,
50         close_behavior: None,
51      }
52   }
53
54   /// Override the chrome that would otherwise come from the installed theme.
55   pub fn visuals(mut self, visuals: ComboBoxVisuals) -> Self {
56      self.visuals = Some(visuals);
57      self
58   }
59
60   /// Optional caption painted to the left of the button.
61   pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
62      self.label = Some(label.into());
63      self
64   }
65
66   /// Set the exact width of the combo box button.
67   /// If not set, the width adapts to the content, icon, and minimum width.
68   pub fn width(mut self, width: f32) -> Self {
69      self.width = Some(width);
70      self
71   }
72
73   /// Set the maximum height of the popup menu.
74   /// Default is `ui.spacing().combo_height`.
75   pub fn popup_max_height(mut self, height: f32) -> Self {
76      self.popup_max_height = Some(height);
77      self
78   }
79
80   /// Replace the default disclosure triangle with a custom painter.
81   ///
82   /// The `bool` argument is `true` while the popup is open.
83   pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self {
84      self.icon = Some(Box::new(icon_fn));
85      self
86   }
87
88   /// Set the wrap mode for the selected text displayed *in the button*.
89   pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
90      self.wrap_mode = Some(wrap_mode);
91      self
92   }
93
94   /// How the popup closes. Default: [`PopupCloseBehavior::CloseOnClick`].
95   pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
96      self.close_behavior = Some(close_behavior);
97      self
98   }
99
100   /// Paint the button and, while open, the popup. Returns the inner response
101   /// of the menu closure when the popup is visible.
102   pub fn show_ui<R>(
103      self,
104      ui: &mut Ui,
105      menu_contents: impl FnOnce(&mut Ui) -> R,
106   ) -> Option<InnerResponse<R>> {
107      let button_id = ui.make_persistent_id(self.id_salt);
108      let popup_id = button_id.with("popup");
109
110      let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id);
111      let visuals = self.visuals.or_else(|| Theme::combo_box_visuals_from_ctx(ui.ctx()));
112
113      // Button Rendering
114      let button_response = combo_box_with_image_button(
115         ui,
116         button_id,
117         is_popup_open,
118         visuals.as_ref(),
119         &self.selected_item,
120         self.icon,
121         self.wrap_mode,
122         (self.width, None),
123      );
124
125      // Interaction
126      if button_response.clicked() {
127         Popup::toggle_id(ui.ctx(), popup_id);
128      }
129
130      // Popup Handling
131      let popup_max_h = self.popup_max_height.unwrap_or_else(|| ui.spacing().combo_height);
132      let popup_max_w = self.width.unwrap_or(ui.available_width());
133      let close_behavior = self.close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
134
135      let popup = Popup::menu(&button_response)
136         .close_behavior(close_behavior)
137         .kind(PopupKind::Tooltip);
138
139      let inner = popup.show(|ui| {
140         ScrollArea::vertical()
141            .max_height(popup_max_h)
142            .max_width(popup_max_w)
143            .show(ui, |ui| {
144               ui.set_width(
145                  ui.available_width()
146                     .max(button_response.rect.width() - ui.spacing().button_padding.x * 2.0),
147               );
148               ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
149               // Menu items often use Label::expand() which paints outside the
150               // allocated rect. The scroll-area clip would otherwise shave the
151               // first/last row highlight (selected looks smaller than hovered).
152               let item_expand_pad = 4.0;
153               ui.add_space(item_expand_pad);
154               let inner = menu_contents(ui);
155               ui.add_space(item_expand_pad);
156               inner
157            })
158            .inner
159      });
160
161      inner
162   }
163}
164
165fn combo_box_with_image_button(
166   ui: &mut Ui,
167   _id: Id,
168   is_popup_open: bool,
169   combo_box_visuals: Option<&ComboBoxVisuals>,
170   selected_item: &Label,
171   icon_painter: Option<Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>>,
172   wrap_mode_override: Option<TextWrapMode>,
173   (width_override, _): (Option<f32>, Option<f32>),
174) -> Response {
175   let button_padding = ui.spacing().button_padding;
176   let icon_width = ui.spacing().icon_width;
177   let icon_spacing = ui.spacing().icon_spacing;
178   let minimum_height = ui.spacing().interact_size.y;
179
180   let wrap_mode = wrap_mode_override.unwrap_or_else(|| ui.wrap_mode());
181
182   // Size Calculation
183   let available_width = ui.available_width();
184   let width_for_layout = if let Some(w) = width_override {
185      (w - button_padding.x * 2.0 - icon_width - icon_spacing).max(0.0)
186   } else {
187      (available_width - button_padding.x * 2.0 - icon_width - icon_spacing).max(10.0)
188   };
189
190   let mut item_for_measurement = selected_item.clone();
191   if wrap_mode_override.is_some() {
192      item_for_measurement = item_for_measurement.wrap_mode(wrap_mode);
193   }
194
195   let (_, content_size) = item_for_measurement.galley_and_size(ui, width_for_layout);
196
197   // Calculate the total inner size needed (content + icon)
198   let inner_width = content_size.x + icon_spacing + icon_width;
199   let inner_height = content_size.y.max(icon_width);
200
201   let mut button_size = Vec2::new(
202      inner_width + button_padding.x * 2.0,
203      inner_height + button_padding.y * 2.0,
204   );
205
206   button_size.y = button_size.y.at_least(minimum_height);
207   if let Some(w) = width_override {
208      button_size.x = w;
209   } else {
210      button_size.x = button_size.x.at_least(ui.spacing().combo_width);
211   }
212
213   // Allocation & Interaction
214   let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
215
216   // Painting
217   if ui.is_rect_visible(rect) {
218      let visuals = if is_popup_open {
219         ui.visuals().widgets.open
220      } else {
221         ui.style().interact(&response).clone()
222      };
223
224      // Paint background
225      let background_rect = rect.expand(visuals.expansion);
226      let corner = combo_box_visuals.map(|v| v.corner_radius).unwrap_or(visuals.corner_radius);
227
228      let fill = combo_box_visuals
229         .map(|v| v.bg_from_res(&response))
230         .unwrap_or(visuals.weak_bg_fill);
231
232      let stroke = combo_box_visuals
233         .map(|v| v.border_from_res(&response))
234         .unwrap_or(visuals.bg_stroke);
235
236      if let Some(vis) = combo_box_visuals {
237         let shadow_shape = vis.shadow.as_shape(background_rect, corner);
238         ui.painter().add(shadow_shape);
239      }
240
241      let rect_shape = RectShape::new(
242         background_rect,
243         corner,
244         fill,
245         stroke,
246         StrokeKind::Inside,
247      );
248
249      ui.painter().add(rect_shape);
250
251      // Area for content (label + image) inside padding
252      let content_total_rect = rect.shrink2(button_padding);
253
254      let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(
255         Vec2::splat(icon_width), // Square icon
256         content_total_rect,
257      );
258
259      // Calculate rect for the LabelWithImage (remaining space to the left of the icon)
260      let label_rect_width = (icon_rect.left() - content_total_rect.left() - icon_spacing).max(0.0);
261      let label_rect = Rect::from_min_size(
262         content_total_rect.min,
263         Vec2::new(label_rect_width, content_total_rect.height()),
264      );
265
266      selected_item.paint_content_within_rect(ui, label_rect, &visuals);
267
268      // Paint the icon
269      if let Some(icon_painter) = icon_painter {
270         icon_painter(ui, icon_rect, &visuals, is_popup_open);
271      } else {
272         paint_default_icon(
273            ui.painter(),
274            icon_rect,
275            combo_box_visuals,
276            &visuals,
277         );
278      }
279   }
280
281   response
282}
283
284fn paint_default_icon(
285   painter: &Painter,
286   rect: Rect,
287   combo_box_visuals: Option<&ComboBoxVisuals>,
288   visuals: &WidgetVisuals,
289) {
290   // Always draw a downward-pointing triangle, matching egui 0.35: the popup's open
291   // direction is now handled automatically by `Popup` (it flips above/below as needed),
292   // and the icon no longer reflects placement.
293   let rect = Rect::from_center_size(
294      rect.center(),
295      Vec2::new(rect.width() * 0.7, rect.height() * 0.45),
296   );
297
298   let points = vec![rect.left_top(), rect.right_top(), rect.center_bottom()];
299
300   let fill = combo_box_visuals.map(|v| v.icon).unwrap_or(visuals.fg_stroke.color);
301   painter.add(Shape::convex_polygon(points, fill, Stroke::NONE));
302}