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 popup_style: StyleModifier,
48}
49
50impl ComboBox {
51 pub fn new(id_salt: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
53 Self {
54 id_salt: Id::new(id_salt),
55 label: Some(label.into()),
56 selected_text: Default::default(),
57 width: None,
58 height: None,
59 icon: None,
60 wrap_mode: None,
61 close_behavior: None,
62 popup_style: StyleModifier::default(),
63 }
64 }
65
66 pub fn from_label(label: impl Into<WidgetText>) -> Self {
68 let label = label.into();
69 Self {
70 id_salt: Id::new(label.text()),
71 label: Some(label),
72 selected_text: Default::default(),
73 width: None,
74 height: None,
75 icon: None,
76 wrap_mode: None,
77 close_behavior: None,
78 popup_style: StyleModifier::default(),
79 }
80 }
81
82 pub fn from_id_salt(id_salt: impl std::hash::Hash) -> Self {
84 Self {
85 id_salt: Id::new(id_salt),
86 label: Default::default(),
87 selected_text: Default::default(),
88 width: None,
89 height: None,
90 icon: None,
91 wrap_mode: None,
92 close_behavior: None,
93 popup_style: StyleModifier::default(),
94 }
95 }
96
97 #[deprecated = "Renamed from_id_salt"]
99 pub fn from_id_source(id_salt: impl std::hash::Hash) -> Self {
100 Self::from_id_salt(id_salt)
101 }
102
103 #[inline]
107 pub fn width(mut self, width: f32) -> Self {
108 self.width = Some(width);
109 self
110 }
111
112 #[inline]
116 pub fn height(mut self, height: f32) -> Self {
117 self.height = Some(height);
118 self
119 }
120
121 #[inline]
123 pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
124 self.selected_text = selected_text.into();
125 self
126 }
127
128 #[inline]
159 pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self {
160 self.icon = Some(Box::new(icon_fn));
161 self
162 }
163
164 #[inline]
170 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
171 self.wrap_mode = Some(wrap_mode);
172 self
173 }
174
175 #[inline]
177 pub fn wrap(mut self) -> Self {
178 self.wrap_mode = Some(TextWrapMode::Wrap);
179 self
180 }
181
182 #[inline]
184 pub fn truncate(mut self) -> Self {
185 self.wrap_mode = Some(TextWrapMode::Truncate);
186 self
187 }
188
189 #[inline]
193 pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
194 self.close_behavior = Some(close_behavior);
195 self
196 }
197
198 #[inline]
203 pub fn popup_style(mut self, popup_style: StyleModifier) -> Self {
204 self.popup_style = popup_style;
205 self
206 }
207
208 pub fn show_ui<R>(
212 self,
213 ui: &mut Ui,
214 menu_contents: impl FnOnce(&mut Ui) -> R,
215 ) -> InnerResponse<Option<R>> {
216 self.show_ui_dyn(ui, Box::new(menu_contents))
217 }
218
219 fn show_ui_dyn<'c, R>(
220 self,
221 ui: &mut Ui,
222 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
223 ) -> InnerResponse<Option<R>> {
224 let Self {
225 id_salt,
226 label,
227 selected_text,
228 width,
229 height,
230 icon,
231 wrap_mode,
232 close_behavior,
233 popup_style,
234 } = self;
235
236 let button_id = ui.make_persistent_id(id_salt);
237
238 ui.horizontal(|ui| {
239 let mut ir = combo_box_dyn(
240 ui,
241 button_id,
242 selected_text.clone(),
243 menu_contents,
244 icon,
245 wrap_mode,
246 close_behavior,
247 popup_style,
248 (width, height),
249 );
250 ir.response.widget_info(|| {
251 let mut info = WidgetInfo::new(WidgetType::ComboBox);
252 info.enabled = ui.is_enabled();
253 info.current_text_value = Some(selected_text.text().to_owned());
254 info
255 });
256 if let Some(label) = label {
257 let label_response = ui.label(label);
258 ir.response = ir.response.labelled_by(label_response.id);
259 ir.response |= label_response;
260 }
261 ir
262 })
263 .inner
264 }
265
266 pub fn show_index<Text: Into<WidgetText>>(
285 self,
286 ui: &mut Ui,
287 selected: &mut usize,
288 len: usize,
289 get: impl Fn(usize) -> Text,
290 ) -> Response {
291 let slf = self.selected_text(get(*selected));
292
293 let mut changed = false;
294
295 let mut response = slf
296 .show_ui(ui, |ui| {
297 for i in 0..len {
298 if ui.selectable_label(i == *selected, get(i)).clicked() {
299 *selected = i;
300 changed = true;
301 }
302 }
303 })
304 .response;
305
306 if changed {
307 response.mark_changed();
308 }
309 response
310 }
311
312 pub fn is_open(ctx: &Context, id: Id) -> bool {
314 Popup::is_id_open(ctx, Self::widget_to_popup_id(id))
315 }
316
317 fn widget_to_popup_id(widget_id: Id) -> Id {
319 widget_id.with("popup")
320 }
321}
322
323#[expect(clippy::too_many_arguments)]
324fn combo_box_dyn<'c, R>(
325 ui: &mut Ui,
326 button_id: Id,
327 selected_text: WidgetText,
328 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
329 icon: Option<IconPainter>,
330 wrap_mode: Option<TextWrapMode>,
331 close_behavior: Option<PopupCloseBehavior>,
332 popup_style: StyleModifier,
333 (width, height): (Option<f32>, Option<f32>),
334) -> InnerResponse<Option<R>> {
335 let popup_id = ComboBox::widget_to_popup_id(button_id);
336
337 let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id);
338
339 let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
340
341 let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
342
343 let margin = ui.spacing().button_padding;
344 let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
345 let icon_spacing = ui.spacing().icon_spacing;
346 let icon_size = Vec2::splat(ui.spacing().icon_width);
347
348 let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
352
353 let wrap_width = if wrap_mode == TextWrapMode::Extend {
355 f32::INFINITY
357 } else {
358 ui.available_width() - icon_spacing - icon_size.x
360 };
361
362 let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
363
364 let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
365 let actual_height = galley.size().y.max(icon_size.y);
366
367 let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
368 let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
369 let response = ui.interact(button_rect, button_id, Sense::click());
370 if ui.is_rect_visible(rect) {
373 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
374 let visuals = if is_popup_open {
375 &ui.visuals().widgets.open
376 } else {
377 ui.style().interact(&response)
378 };
379
380 if let Some(icon) = icon {
381 icon(
382 ui,
383 icon_rect.expand(visuals.expansion),
384 visuals,
385 is_popup_open,
386 );
387 } else {
388 paint_default_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals);
389 }
390
391 let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
392 ui.painter()
393 .galley(text_rect.min, galley, visuals.text_color());
394 }
395 });
396
397 let height = height.unwrap_or_else(|| ui.spacing().combo_height);
398
399 let inner = Popup::menu(&button_response)
400 .id(popup_id)
401 .width(button_response.rect.width())
402 .close_behavior(close_behavior)
403 .style(popup_style)
404 .show(|ui| {
405 ui.set_min_width(ui.available_width());
406
407 ScrollArea::vertical()
408 .max_height(height)
409 .show(ui, |ui| {
410 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
416 menu_contents(ui)
417 })
418 .inner
419 })
420 .map(|r| r.inner);
421
422 InnerResponse {
423 inner,
424 response: button_response,
425 }
426}
427
428fn button_frame(
429 ui: &mut Ui,
430 id: Id,
431 is_popup_open: bool,
432 sense: Sense,
433 add_contents: impl FnOnce(&mut Ui),
434) -> Response {
435 let where_to_put_background = ui.painter().add(Shape::Noop);
436
437 let margin = ui.spacing().button_padding;
438 let interact_size = ui.spacing().interact_size;
439
440 let mut outer_rect = ui.available_rect_before_wrap();
441 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
442
443 let inner_rect = outer_rect.shrink2(margin);
444 let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect));
445 add_contents(&mut content_ui);
446
447 let mut outer_rect = content_ui.min_rect().expand2(margin);
448 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
449
450 let response = ui.interact(outer_rect, id, sense);
451
452 if ui.is_rect_visible(outer_rect) {
453 let visuals = if is_popup_open {
454 &ui.visuals().widgets.open
455 } else {
456 ui.style().interact(&response)
457 };
458
459 ui.painter().set(
460 where_to_put_background,
461 epaint::RectShape::new(
462 outer_rect.expand(visuals.expansion),
463 visuals.corner_radius,
464 visuals.weak_bg_fill,
465 visuals.bg_stroke,
466 epaint::StrokeKind::Inside,
467 ),
468 );
469 }
470
471 ui.advance_cursor_after_rect(outer_rect);
472
473 response
474}
475
476fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) {
477 let rect = Rect::from_center_size(
478 rect.center(),
479 vec2(rect.width() * 0.7, rect.height() * 0.45),
480 );
481
482 painter.add(Shape::convex_polygon(
487 vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
488 visuals.fg_stroke.color,
489 Stroke::NONE,
490 ));
491}