Skip to main content

zeus_widgets/
combo_box.rs

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