zeus_widgets/
combo_box.rs

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