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};
8
9#[must_use = "You should call .show_ui()"]
10pub struct ComboBox {
11 id_salt: Id,
12 label: Option<WidgetText>,
13 selected_item: Label,
14 width: Option<f32>,
15 popup_max_height: Option<f32>,
16 icon: Option<Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>>,
17 wrap_mode: Option<TextWrapMode>,
18 close_behavior: Option<PopupCloseBehavior>,
19}
20
21impl ComboBox {
22 pub fn new(id_salt: impl std::hash::Hash, selected_item: Label) -> Self {
23 Self {
24 id_salt: Id::new(id_salt),
25 label: None,
26 selected_item,
27 width: None,
28 popup_max_height: None,
29 icon: None,
30 wrap_mode: None,
31 close_behavior: None,
32 }
33 }
34
35 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
36 self.label = Some(label.into());
37 self
38 }
39
40 pub fn width(mut self, width: f32) -> Self {
43 self.width = Some(width);
44 self
45 }
46
47 pub fn popup_max_height(mut self, height: f32) -> Self {
50 self.popup_max_height = Some(height);
51 self
52 }
53
54 pub fn icon(
55 mut self,
56 icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
57 ) -> Self {
58 self.icon = Some(Box::new(icon_fn));
59 self
60 }
61
62 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
64 self.wrap_mode = Some(wrap_mode);
65 self
66 }
67
68 pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
69 self.close_behavior = Some(close_behavior);
70 self
71 }
72
73 pub fn show_ui<R>(
74 self,
75 ui: &mut Ui,
76 menu_contents: impl FnOnce(&mut Ui) -> R,
77 ) -> Option<InnerResponse<R>> {
78 let button_id = ui.make_persistent_id(self.id_salt);
79 let popup_id = button_id.with("popup");
80
81 let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id);
82
83 let button_response = combo_box_with_image_button(
85 ui,
86 button_id,
87 is_popup_open,
88 &self.selected_item,
89 self.icon,
90 self.wrap_mode,
91 (self.width, None),
92 );
93
94 if button_response.clicked() {
96 Popup::toggle_id(ui.ctx(), popup_id);
97 }
98
99 let popup_default_height = 200.0;
101 let popup_current_height = ui.memory(|m| {
102 m.area_rect(popup_id).map(|rect| rect.height()).unwrap_or(popup_default_height)
103 });
104
105 let button_bottom = button_response.rect.bottom();
106 let screen_bottom = ui.ctx().content_rect().bottom();
107
108 let space_below = screen_bottom - button_bottom;
109
110 let _above_or_below = if space_below >= popup_current_height
111 || space_below >= ui.spacing().interact_size.y * 4.0
112 {
113 AboveOrBelow::Below
114 } else {
115 AboveOrBelow::Above
116 };
117
118 let popup_max_h = self.popup_max_height.unwrap_or_else(|| ui.spacing().combo_height);
119 let popup_max_w = self.width.unwrap_or(ui.available_width());
120 let close_behavior = self.close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
121
122 let popup = Popup::menu(&button_response)
123 .close_behavior(close_behavior)
124 .kind(PopupKind::Tooltip);
125
126 let inner = popup.show(|ui| {
127 ScrollArea::vertical()
128 .max_height(popup_max_h)
129 .max_width(popup_max_w)
130 .show(ui, |ui| {
131 ui.set_width(
132 ui.available_width()
133 .max(button_response.rect.width() - ui.spacing().button_padding.x * 2.0),
134 );
135 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
136 menu_contents(ui)
137 })
138 .inner
139 });
140
141 inner
142 }
143}
144
145fn combo_box_with_image_button(
146 ui: &mut Ui,
147 _id: Id,
148 is_popup_open: bool,
149 selected_item: &Label,
150 icon_painter: Option<Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>>,
151 wrap_mode_override: Option<TextWrapMode>,
152 (width_override, _): (Option<f32>, Option<f32>),
153) -> Response {
154 let button_padding = ui.spacing().button_padding;
155 let icon_width = ui.spacing().icon_width;
156 let icon_spacing = ui.spacing().icon_spacing;
157 let minimum_height = ui.spacing().interact_size.y;
158
159 let wrap_mode = wrap_mode_override.unwrap_or_else(|| ui.wrap_mode());
160
161 let available_width = ui.available_width();
163 let width_for_layout = if let Some(w) = width_override {
164 (w - button_padding.x * 2.0 - icon_width - icon_spacing).max(0.0)
165 } else {
166 (available_width - button_padding.x * 2.0 - icon_width - icon_spacing).max(10.0)
167 };
168
169 let mut item_for_measurement = selected_item.clone();
170 if wrap_mode_override.is_some() {
171 item_for_measurement = item_for_measurement.wrap_mode(wrap_mode);
172 }
173
174 let (_, content_size) = item_for_measurement.galley_and_size(ui, width_for_layout);
175
176 let inner_width = content_size.x + icon_spacing + icon_width;
178 let inner_height = content_size.y.max(icon_width);
179
180 let mut button_size = Vec2::new(
181 inner_width + button_padding.x * 2.0,
182 inner_height + button_padding.y * 2.0,
183 );
184
185 button_size.y = button_size.y.at_least(minimum_height);
186 if let Some(w) = width_override {
187 button_size.x = w;
188 } else {
189 button_size.x = button_size.x.at_least(ui.spacing().combo_width);
190 }
191
192 let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
194
195 if ui.is_rect_visible(rect) {
197 let visuals = if is_popup_open {
198 ui.visuals().widgets.open
199 } else {
200 ui.style().interact(&response).clone()
201 };
202
203 let background_rect = rect.expand(visuals.expansion);
205 ui.painter().add(RectShape::new(
206 background_rect,
207 visuals.corner_radius,
208 visuals.weak_bg_fill,
209 visuals.bg_stroke,
210 StrokeKind::Inside,
211 ));
212
213 let content_total_rect = rect.shrink2(button_padding);
215
216 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(
217 Vec2::splat(icon_width), content_total_rect,
219 );
220
221 let label_rect_width = (icon_rect.left() - content_total_rect.left() - icon_spacing).max(0.0);
223 let label_rect = Rect::from_min_size(
224 content_total_rect.min,
225 Vec2::new(label_rect_width, content_total_rect.height()),
226 );
227
228 selected_item.paint_content_within_rect(ui, label_rect, &visuals);
229
230 let popup_peek_height = 50.0;
231 let above_or_below =
232 if response.rect.bottom() + popup_peek_height < ui.ctx().content_rect().bottom() {
233 AboveOrBelow::Below
234 } else {
235 AboveOrBelow::Above
236 };
237
238 if let Some(icon_painter) = icon_painter {
240 icon_painter(
241 ui,
242 icon_rect,
243 &visuals,
244 is_popup_open,
245 above_or_below,
246 );
247 } else {
248 paint_default_icon(ui.painter(), icon_rect, &visuals, above_or_below);
249 }
250 }
251
252 response
253}
254
255fn paint_default_icon(
256 painter: &Painter,
257 rect: Rect,
258 visuals: &WidgetVisuals,
259 above_or_below: AboveOrBelow,
260) {
261 let rect = Rect::from_center_size(
262 rect.center(),
263 Vec2::new(rect.width() * 0.7, rect.height() * 0.45),
264 );
265
266 let points = match above_or_below {
267 AboveOrBelow::Above => vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
268 AboveOrBelow::Below => vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
269 };
270
271 painter.add(Shape::convex_polygon(
272 points,
273 visuals.fg_stroke.color,
274 Stroke::NONE,
275 ));
276}