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#[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 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 pub fn visuals(mut self, visuals: ComboBoxVisuals) -> Self {
56 self.visuals = Some(visuals);
57 self
58 }
59
60 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
62 self.label = Some(label.into());
63 self
64 }
65
66 pub fn width(mut self, width: f32) -> Self {
69 self.width = Some(width);
70 self
71 }
72
73 pub fn popup_max_height(mut self, height: f32) -> Self {
76 self.popup_max_height = Some(height);
77 self
78 }
79
80 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 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
90 self.wrap_mode = Some(wrap_mode);
91 self
92 }
93
94 pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
96 self.close_behavior = Some(close_behavior);
97 self
98 }
99
100 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 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 if button_response.clicked() {
127 Popup::toggle_id(ui.ctx(), popup_id);
128 }
129
130 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 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 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 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 let (rect, response) = ui.allocate_exact_size(button_size, Sense::click());
215
216 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 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 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), content_total_rect,
257 );
258
259 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 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 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}