1use epaint::Shape;
2
3use crate::{
4 Align2, Context, Id, InnerResponse, NumExt as _, Painter, Popup, PopupCloseBehavior, Rect,
5 Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui, UiBuilder, Vec2, WidgetInfo,
6 WidgetText, WidgetType, epaint, style::StyleModifier, style::WidgetVisuals, vec2,
7};
8
9#[expect(unused_imports)] use crate::style::Spacing;
11
12pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool)>;
14
15#[must_use = "You should call .show*"]
38pub struct ComboBox {
39 id_salt: Id,
40 label: Option<WidgetText>,
41 selected_text: WidgetText,
42 width: Option<f32>,
43 height: Option<f32>,
44 icon: Option<IconPainter>,
45 wrap_mode: Option<TextWrapMode>,
46 close_behavior: Option<PopupCloseBehavior>,
47}
48
49impl ComboBox {
50 pub fn new(id_salt: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
52 Self {
53 id_salt: Id::new(id_salt),
54 label: Some(label.into()),
55 selected_text: Default::default(),
56 width: None,
57 height: None,
58 icon: None,
59 wrap_mode: None,
60 close_behavior: None,
61 }
62 }
63
64 pub fn from_label(label: impl Into<WidgetText>) -> Self {
66 let label = label.into();
67 Self {
68 id_salt: Id::new(label.text()),
69 label: Some(label),
70 selected_text: Default::default(),
71 width: None,
72 height: None,
73 icon: None,
74 wrap_mode: None,
75 close_behavior: None,
76 }
77 }
78
79 pub fn from_id_salt(id_salt: impl std::hash::Hash) -> Self {
81 Self {
82 id_salt: Id::new(id_salt),
83 label: Default::default(),
84 selected_text: Default::default(),
85 width: None,
86 height: None,
87 icon: None,
88 wrap_mode: None,
89 close_behavior: None,
90 }
91 }
92
93 #[deprecated = "Renamed from_id_salt"]
95 pub fn from_id_source(id_salt: impl std::hash::Hash) -> Self {
96 Self::from_id_salt(id_salt)
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 pub fn show_ui<R>(
198 self,
199 ui: &mut Ui,
200 menu_contents: impl FnOnce(&mut Ui) -> R,
201 ) -> InnerResponse<Option<R>> {
202 self.show_ui_dyn(ui, Box::new(menu_contents))
203 }
204
205 fn show_ui_dyn<'c, R>(
206 self,
207 ui: &mut Ui,
208 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
209 ) -> InnerResponse<Option<R>> {
210 let Self {
211 id_salt,
212 label,
213 selected_text,
214 width,
215 height,
216 icon,
217 wrap_mode,
218 close_behavior,
219 } = self;
220
221 let button_id = ui.make_persistent_id(id_salt);
222
223 ui.horizontal(|ui| {
224 let mut ir = combo_box_dyn(
225 ui,
226 button_id,
227 selected_text,
228 menu_contents,
229 icon,
230 wrap_mode,
231 close_behavior,
232 (width, height),
233 );
234 if let Some(label) = label {
235 ir.response.widget_info(|| {
236 WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text())
237 });
238 ir.response |= ui.label(label);
239 } else {
240 ir.response
241 .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), ""));
242 }
243 ir
244 })
245 .inner
246 }
247
248 pub fn show_index<Text: Into<WidgetText>>(
267 self,
268 ui: &mut Ui,
269 selected: &mut usize,
270 len: usize,
271 get: impl Fn(usize) -> Text,
272 ) -> Response {
273 let slf = self.selected_text(get(*selected));
274
275 let mut changed = false;
276
277 let mut response = slf
278 .show_ui(ui, |ui| {
279 for i in 0..len {
280 if ui.selectable_label(i == *selected, get(i)).clicked() {
281 *selected = i;
282 changed = true;
283 }
284 }
285 })
286 .response;
287
288 if changed {
289 response.mark_changed();
290 }
291 response
292 }
293
294 pub fn is_open(ctx: &Context, id: Id) -> bool {
296 Popup::is_id_open(ctx, Self::widget_to_popup_id(id))
297 }
298
299 fn widget_to_popup_id(widget_id: Id) -> Id {
301 widget_id.with("popup")
302 }
303}
304
305#[expect(clippy::too_many_arguments)]
306fn combo_box_dyn<'c, R>(
307 ui: &mut Ui,
308 button_id: Id,
309 selected_text: WidgetText,
310 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
311 icon: Option<IconPainter>,
312 wrap_mode: Option<TextWrapMode>,
313 close_behavior: Option<PopupCloseBehavior>,
314 (width, height): (Option<f32>, Option<f32>),
315) -> InnerResponse<Option<R>> {
316 let popup_id = ComboBox::widget_to_popup_id(button_id);
317
318 let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id);
319
320 let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
321
322 let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
323
324 let margin = ui.spacing().button_padding;
325 let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
326 let icon_spacing = ui.spacing().icon_spacing;
327 let icon_size = Vec2::splat(ui.spacing().icon_width);
328
329 let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
333
334 let wrap_width = if wrap_mode == TextWrapMode::Extend {
336 f32::INFINITY
338 } else {
339 ui.available_width() - icon_spacing - icon_size.x
341 };
342
343 let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
344
345 let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
346 let actual_height = galley.size().y.max(icon_size.y);
347
348 let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
349 let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
350 let response = ui.interact(button_rect, button_id, Sense::click());
351 if ui.is_rect_visible(rect) {
354 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
355 let visuals = if is_popup_open {
356 &ui.visuals().widgets.open
357 } else {
358 ui.style().interact(&response)
359 };
360
361 if let Some(icon) = icon {
362 icon(
363 ui,
364 icon_rect.expand(visuals.expansion),
365 visuals,
366 is_popup_open,
367 );
368 } else {
369 paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals);
370 }
371
372 let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
373 ui.painter()
374 .galley(text_rect.min, galley, visuals.text_color());
375 }
376 });
377
378 let height = height.unwrap_or_else(|| ui.spacing().combo_height);
379
380 let inner = Popup::menu(&button_response)
381 .id(popup_id)
382 .style(StyleModifier::default())
383 .width(button_response.rect.width())
384 .close_behavior(close_behavior)
385 .show(|ui| {
386 ui.set_min_width(ui.available_width());
387
388 ScrollArea::vertical()
389 .max_height(height)
390 .show(ui, |ui| {
391 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
397 menu_contents(ui)
398 })
399 .inner
400 })
401 .map(|r| r.inner);
402
403 InnerResponse {
404 inner,
405 response: button_response,
406 }
407}
408
409fn button_frame(
410 ui: &mut Ui,
411 id: Id,
412 is_popup_open: bool,
413 sense: Sense,
414 add_contents: impl FnOnce(&mut Ui),
415) -> Response {
416 let where_to_put_background = ui.painter().add(Shape::Noop);
417
418 let margin = ui.spacing().button_padding;
419 let interact_size = ui.spacing().interact_size;
420
421 let mut outer_rect = ui.available_rect_before_wrap();
422 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
423
424 let inner_rect = outer_rect.shrink2(margin);
425 let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect));
426 add_contents(&mut content_ui);
427
428 let mut outer_rect = content_ui.min_rect().expand2(margin);
429 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
430
431 let response = ui.interact(outer_rect, id, sense);
432
433 if ui.is_rect_visible(outer_rect) {
434 let visuals = if is_popup_open {
435 &ui.visuals().widgets.open
436 } else {
437 ui.style().interact(&response)
438 };
439
440 ui.painter().set(
441 where_to_put_background,
442 epaint::RectShape::new(
443 outer_rect.expand(visuals.expansion),
444 visuals.corner_radius,
445 visuals.weak_bg_fill,
446 visuals.bg_stroke,
447 epaint::StrokeKind::Inside,
448 ),
449 );
450 }
451
452 ui.advance_cursor_after_rect(outer_rect);
453
454 response
455}
456
457fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) {
458 let rect = Rect::from_center_size(
459 rect.center(),
460 vec2(rect.width() * 0.7, rect.height() * 0.45),
461 );
462
463 painter.add(Shape::convex_polygon(
468 vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
469 visuals.fg_stroke.color,
470 Stroke::NONE,
471 ));
472}