1use epaint::Shape;
2
3use crate::{
4 Align2, AsIdSalt, Context, Id, IdSalt, InnerResponse, NumExt as _, Painter, Popup,
5 PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui,
6 UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, epaint,
7 style::{StyleModifier, WidgetVisuals},
8 vec2,
9};
10
11#[expect(unused_imports)] use crate::style::Spacing;
13
14pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>;
16
17#[must_use = "You should call .show*"]
40pub struct ComboBox {
41 id_salt: IdSalt,
42 label: Option<WidgetText>,
43 selected_text: WidgetText,
44 width: Option<f32>,
45 height: Option<f32>,
46 icon: Option<IconPainter>,
47 wrap_mode: Option<TextWrapMode>,
48 close_behavior: Option<PopupCloseBehavior>,
49 popup_style: StyleModifier,
50}
51
52impl ComboBox {
53 pub fn new(id_salt: impl AsIdSalt, label: impl Into<WidgetText>) -> Self {
55 Self {
56 id_salt: IdSalt::new(id_salt),
57 label: Some(label.into()),
58 selected_text: Default::default(),
59 width: None,
60 height: None,
61 icon: None,
62 wrap_mode: None,
63 close_behavior: None,
64 popup_style: StyleModifier::default(),
65 }
66 }
67
68 pub fn from_label(label: impl Into<WidgetText>) -> Self {
70 let label = label.into();
71 Self {
72 id_salt: IdSalt::new(label.text()),
73 label: Some(label),
74 selected_text: Default::default(),
75 width: None,
76 height: None,
77 icon: None,
78 wrap_mode: None,
79 close_behavior: None,
80 popup_style: StyleModifier::default(),
81 }
82 }
83
84 pub fn from_id_salt(id_salt: impl AsIdSalt) -> Self {
86 Self {
87 id_salt: IdSalt::new(id_salt),
88 label: Default::default(),
89 selected_text: Default::default(),
90 width: None,
91 height: None,
92 icon: None,
93 wrap_mode: None,
94 close_behavior: None,
95 popup_style: StyleModifier::default(),
96 }
97 }
98
99 #[inline]
103 pub fn width(mut self, width: f32) -> Self {
104 self.width = Some(width);
105 self
106 }
107
108 #[inline]
112 pub fn height(mut self, height: f32) -> Self {
113 self.height = Some(height);
114 self
115 }
116
117 #[inline]
119 pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
120 self.selected_text = selected_text.into();
121 self
122 }
123
124 #[inline]
155 pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self {
156 self.icon = Some(Box::new(icon_fn));
157 self
158 }
159
160 #[inline]
166 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
167 self.wrap_mode = Some(wrap_mode);
168 self
169 }
170
171 #[inline]
173 pub fn wrap(mut self) -> Self {
174 self.wrap_mode = Some(TextWrapMode::Wrap);
175 self
176 }
177
178 #[inline]
180 pub fn truncate(mut self) -> Self {
181 self.wrap_mode = Some(TextWrapMode::Truncate);
182 self
183 }
184
185 #[inline]
189 pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
190 self.close_behavior = Some(close_behavior);
191 self
192 }
193
194 #[inline]
199 pub fn popup_style(mut self, popup_style: StyleModifier) -> Self {
200 self.popup_style = popup_style;
201 self
202 }
203
204 pub fn show_ui<R>(
208 self,
209 ui: &mut Ui,
210 menu_contents: impl FnOnce(&mut Ui) -> R,
211 ) -> InnerResponse<Option<R>> {
212 self.show_ui_dyn(ui, Box::new(menu_contents))
213 }
214
215 fn show_ui_dyn<'c, R>(
216 self,
217 ui: &mut Ui,
218 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
219 ) -> InnerResponse<Option<R>> {
220 let Self {
221 id_salt,
222 label,
223 selected_text,
224 width,
225 height,
226 icon,
227 wrap_mode,
228 close_behavior,
229 popup_style,
230 } = self;
231
232 let button_id = ui.make_persistent_id(id_salt);
233
234 ui.horizontal(|ui| {
235 let mut ir = combo_box_dyn(
236 ui,
237 button_id,
238 selected_text.clone(),
239 menu_contents,
240 icon,
241 wrap_mode,
242 close_behavior,
243 popup_style,
244 (width, height),
245 );
246 ir.response.widget_info(|| {
247 let mut info = WidgetInfo::new(WidgetType::ComboBox);
248 info.enabled = ui.is_enabled();
249 info.current_text_value = Some(selected_text.text().to_owned());
250 info
251 });
252 if let Some(label) = label {
253 let label_response = ui.label(label);
254 ir.response = ir.response.labelled_by(label_response.id);
255 ir.response |= label_response;
256 }
257 ir
258 })
259 .inner
260 }
261
262 pub fn show_index<Text: Into<WidgetText>>(
281 self,
282 ui: &mut Ui,
283 selected: &mut usize,
284 len: usize,
285 get: impl Fn(usize) -> Text,
286 ) -> Response {
287 let slf = self.selected_text(get(*selected));
288
289 let mut changed = false;
290
291 let mut response = slf
292 .show_ui(ui, |ui| {
293 for i in 0..len {
294 if ui.selectable_label(i == *selected, get(i)).clicked() {
295 *selected = i;
296 changed = true;
297 }
298 }
299 })
300 .response;
301
302 if changed {
303 response.mark_changed();
304 }
305 response
306 }
307
308 pub fn is_open(ctx: &Context, id: Id) -> bool {
310 Popup::is_id_open(ctx, Self::widget_to_popup_id(id))
311 }
312
313 fn widget_to_popup_id(widget_id: Id) -> Id {
315 widget_id.with("popup")
316 }
317}
318
319#[expect(clippy::too_many_arguments)]
320fn combo_box_dyn<'c, R>(
321 ui: &mut Ui,
322 button_id: Id,
323 selected_text: WidgetText,
324 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
325 icon: Option<IconPainter>,
326 wrap_mode: Option<TextWrapMode>,
327 close_behavior: Option<PopupCloseBehavior>,
328 popup_style: StyleModifier,
329 (width, height): (Option<f32>, Option<f32>),
330) -> InnerResponse<Option<R>> {
331 let popup_id = ComboBox::widget_to_popup_id(button_id);
332
333 let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id);
334
335 let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
336
337 let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
338
339 let margin = ui.spacing().button_padding;
340 let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
341 let icon_spacing = ui.spacing().icon_spacing;
342 let icon_size = Vec2::splat(ui.spacing().icon_width);
343
344 let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
348
349 let wrap_width = if wrap_mode == TextWrapMode::Extend {
351 f32::INFINITY
353 } else {
354 ui.available_width() - icon_spacing - icon_size.x
356 };
357
358 let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
359
360 let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
361 let actual_height = galley.size().y.max(icon_size.y);
362
363 let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
364 let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
365 let response = ui.interact(button_rect, button_id, Sense::click());
366 if ui.is_rect_visible(rect) {
369 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
370 let visuals = if is_popup_open {
371 &ui.visuals().widgets.open
372 } else {
373 ui.style().interact(&response)
374 };
375
376 if let Some(icon) = icon {
377 icon(
378 ui,
379 icon_rect.expand(visuals.expansion),
380 visuals,
381 is_popup_open,
382 );
383 } else {
384 paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals);
385 }
386
387 let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
388 ui.painter()
389 .galley(text_rect.min, galley, visuals.text_color());
390 }
391 });
392
393 let height = height.unwrap_or_else(|| ui.spacing().combo_height);
394
395 let inner = Popup::menu(&button_response)
396 .id(popup_id)
397 .width(button_response.rect.width())
398 .close_behavior(close_behavior)
399 .style(popup_style)
400 .show(|ui| {
401 ui.set_min_width(ui.available_width());
402
403 ScrollArea::vertical()
404 .max_height(height)
405 .show(ui, |ui| {
406 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
412 menu_contents(ui)
413 })
414 .inner
415 })
416 .map(|r| r.inner);
417
418 InnerResponse {
419 inner,
420 response: button_response,
421 }
422}
423
424fn button_frame(
425 ui: &mut Ui,
426 id: Id,
427 is_popup_open: bool,
428 sense: Sense,
429 add_contents: impl FnOnce(&mut Ui),
430) -> Response {
431 let where_to_put_background = ui.painter().add(Shape::Noop);
432
433 let margin = ui.spacing().button_padding;
434 let interact_size = ui.spacing().interact_size;
435
436 let mut outer_rect = ui.available_rect_before_wrap();
437 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
438
439 let inner_rect = outer_rect.shrink2(margin);
440 let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect));
441 add_contents(&mut content_ui);
442
443 let mut outer_rect = content_ui.min_rect().expand2(margin);
444 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
445
446 let response = ui.interact(outer_rect, id, sense);
447
448 if ui.is_rect_visible(outer_rect) {
449 let visuals = if is_popup_open {
450 &ui.visuals().widgets.open
451 } else {
452 ui.style().interact(&response)
453 };
454
455 ui.painter().set(
456 where_to_put_background,
457 epaint::RectShape::new(
458 outer_rect.expand(visuals.expansion),
459 visuals.corner_radius,
460 visuals.weak_bg_fill,
461 visuals.bg_stroke,
462 epaint::StrokeKind::Inside,
463 ),
464 );
465 }
466
467 ui.advance_cursor_after_rect(outer_rect);
468
469 response
470}
471
472fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) {
473 let rect = Rect::from_center_size(
474 rect.center(),
475 vec2(rect.width() * 0.7, rect.height() * 0.45),
476 );
477
478 painter.add(Shape::convex_polygon(
483 vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
484 visuals.fg_stroke.color,
485 Stroke::NONE,
486 ));
487}