1use epaint::Shape;
2
3use crate::{
4 epaint, style::WidgetVisuals, vec2, Align2, Context, Id, InnerResponse, NumExt, Painter,
5 PopupCloseBehavior, Rect, Response, ScrollArea, Sense, Stroke, TextStyle, TextWrapMode, Ui,
6 UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType,
7};
8
9#[allow(unused_imports)] use crate::style::Spacing;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum AboveOrBelow {
15 Above,
16 Below,
17}
18
19pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
21
22#[must_use = "You should call .show*"]
40pub struct ComboBox {
41 id_salt: Id,
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}
50
51impl ComboBox {
52 pub fn new(id_salt: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
54 Self {
55 id_salt: Id::new(id_salt),
56 label: Some(label.into()),
57 selected_text: Default::default(),
58 width: None,
59 height: None,
60 icon: None,
61 wrap_mode: None,
62 close_behavior: None,
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 }
79 }
80
81 pub fn from_id_salt(id_salt: impl std::hash::Hash) -> Self {
83 Self {
84 id_salt: Id::new(id_salt),
85 label: Default::default(),
86 selected_text: Default::default(),
87 width: None,
88 height: None,
89 icon: None,
90 wrap_mode: None,
91 close_behavior: None,
92 }
93 }
94
95 #[deprecated = "Renamed id_salt"]
97 pub fn from_id_source(id_salt: impl std::hash::Hash) -> Self {
98 Self::from_id_salt(id_salt)
99 }
100
101 #[inline]
105 pub fn width(mut self, width: f32) -> Self {
106 self.width = Some(width);
107 self
108 }
109
110 #[inline]
114 pub fn height(mut self, height: f32) -> Self {
115 self.height = Some(height);
116 self
117 }
118
119 #[inline]
121 pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
122 self.selected_text = selected_text.into();
123 self
124 }
125
126 pub fn icon(
158 mut self,
159 icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
160 ) -> Self {
161 self.icon = Some(Box::new(icon_fn));
162 self
163 }
164
165 #[inline]
171 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
172 self.wrap_mode = Some(wrap_mode);
173 self
174 }
175
176 #[inline]
178 pub fn wrap(mut self) -> Self {
179 self.wrap_mode = Some(TextWrapMode::Wrap);
180 self
181 }
182
183 #[inline]
185 pub fn truncate(mut self) -> Self {
186 self.wrap_mode = Some(TextWrapMode::Truncate);
187 self
188 }
189
190 #[inline]
194 pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self {
195 self.close_behavior = Some(close_behavior);
196 self
197 }
198
199 pub fn show_ui<R>(
203 self,
204 ui: &mut Ui,
205 menu_contents: impl FnOnce(&mut Ui) -> R,
206 ) -> InnerResponse<Option<R>> {
207 self.show_ui_dyn(ui, Box::new(menu_contents))
208 }
209
210 fn show_ui_dyn<'c, R>(
211 self,
212 ui: &mut Ui,
213 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
214 ) -> InnerResponse<Option<R>> {
215 let Self {
216 id_salt,
217 label,
218 selected_text,
219 width,
220 height,
221 icon,
222 wrap_mode,
223 close_behavior,
224 } = self;
225
226 let button_id = ui.make_persistent_id(id_salt);
227
228 ui.horizontal(|ui| {
229 let mut ir = combo_box_dyn(
230 ui,
231 button_id,
232 selected_text,
233 menu_contents,
234 icon,
235 wrap_mode,
236 close_behavior,
237 (width, height),
238 );
239 if let Some(label) = label {
240 ir.response.widget_info(|| {
241 WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text())
242 });
243 ir.response |= ui.label(label);
244 } else {
245 ir.response
246 .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), ""));
247 }
248 ir
249 })
250 .inner
251 }
252
253 pub fn show_index<Text: Into<WidgetText>>(
272 self,
273 ui: &mut Ui,
274 selected: &mut usize,
275 len: usize,
276 get: impl Fn(usize) -> Text,
277 ) -> Response {
278 let slf = self.selected_text(get(*selected));
279
280 let mut changed = false;
281
282 let mut response = slf
283 .show_ui(ui, |ui| {
284 for i in 0..len {
285 if ui.selectable_label(i == *selected, get(i)).clicked() {
286 *selected = i;
287 changed = true;
288 }
289 }
290 })
291 .response;
292
293 if changed {
294 response.mark_changed();
295 }
296 response
297 }
298
299 pub fn is_open(ctx: &Context, id: Id) -> bool {
301 ctx.memory(|m| m.is_popup_open(Self::widget_to_popup_id(id)))
302 }
303
304 fn widget_to_popup_id(widget_id: Id) -> Id {
306 widget_id.with("popup")
307 }
308}
309
310#[allow(clippy::too_many_arguments)]
311fn combo_box_dyn<'c, R>(
312 ui: &mut Ui,
313 button_id: Id,
314 selected_text: WidgetText,
315 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
316 icon: Option<IconPainter>,
317 wrap_mode: Option<TextWrapMode>,
318 close_behavior: Option<PopupCloseBehavior>,
319 (width, height): (Option<f32>, Option<f32>),
320) -> InnerResponse<Option<R>> {
321 let popup_id = ComboBox::widget_to_popup_id(button_id);
322
323 let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
324
325 let popup_height = ui.memory(|m| {
326 m.areas()
327 .get(popup_id)
328 .and_then(|state| state.size)
329 .map_or(100.0, |size| size.y)
330 });
331
332 let above_or_below =
333 if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
334 < ui.ctx().screen_rect().bottom()
335 {
336 AboveOrBelow::Below
337 } else {
338 AboveOrBelow::Above
339 };
340
341 let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
342
343 let close_behavior = close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick);
344
345 let margin = ui.spacing().button_padding;
346 let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
347 let icon_spacing = ui.spacing().icon_spacing;
348 let icon_size = Vec2::splat(ui.spacing().icon_width);
349
350 let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
354
355 let wrap_width = if wrap_mode == TextWrapMode::Extend {
357 f32::INFINITY
359 } else {
360 ui.available_width() - icon_spacing - icon_size.x
362 };
363
364 let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
365
366 let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
367 let actual_height = galley.size().y.max(icon_size.y);
368
369 let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
370 let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
371 let response = ui.interact(button_rect, button_id, Sense::click());
372 if ui.is_rect_visible(rect) {
375 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
376 let visuals = if is_popup_open {
377 &ui.visuals().widgets.open
378 } else {
379 ui.style().interact(&response)
380 };
381
382 if let Some(icon) = icon {
383 icon(
384 ui,
385 icon_rect.expand(visuals.expansion),
386 visuals,
387 is_popup_open,
388 above_or_below,
389 );
390 } else {
391 paint_default_icon(
392 ui.painter(),
393 icon_rect.expand(visuals.expansion),
394 visuals,
395 above_or_below,
396 );
397 }
398
399 let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect);
400 ui.painter()
401 .galley(text_rect.min, galley, visuals.text_color());
402 }
403 });
404
405 if button_response.clicked() {
406 ui.memory_mut(|mem| mem.toggle_popup(popup_id));
407 }
408
409 let height = height.unwrap_or_else(|| ui.spacing().combo_height);
410
411 let inner = crate::popup::popup_above_or_below_widget(
412 ui,
413 popup_id,
414 &button_response,
415 above_or_below,
416 close_behavior,
417 |ui| {
418 ScrollArea::vertical()
419 .max_height(height)
420 .show(ui, |ui| {
421 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
427 menu_contents(ui)
428 })
429 .inner
430 },
431 );
432
433 InnerResponse {
434 inner,
435 response: button_response,
436 }
437}
438
439fn button_frame(
440 ui: &mut Ui,
441 id: Id,
442 is_popup_open: bool,
443 sense: Sense,
444 add_contents: impl FnOnce(&mut Ui),
445) -> Response {
446 let where_to_put_background = ui.painter().add(Shape::Noop);
447
448 let margin = ui.spacing().button_padding;
449 let interact_size = ui.spacing().interact_size;
450
451 let mut outer_rect = ui.available_rect_before_wrap();
452 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
453
454 let inner_rect = outer_rect.shrink2(margin);
455 let mut content_ui = ui.new_child(UiBuilder::new().max_rect(inner_rect));
456 add_contents(&mut content_ui);
457
458 let mut outer_rect = content_ui.min_rect().expand2(margin);
459 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
460
461 let response = ui.interact(outer_rect, id, sense);
462
463 if ui.is_rect_visible(outer_rect) {
464 let visuals = if is_popup_open {
465 &ui.visuals().widgets.open
466 } else {
467 ui.style().interact(&response)
468 };
469
470 ui.painter().set(
471 where_to_put_background,
472 epaint::RectShape::new(
473 outer_rect.expand(visuals.expansion),
474 visuals.corner_radius,
475 visuals.weak_bg_fill,
476 visuals.bg_stroke,
477 epaint::StrokeKind::Inside,
478 ),
479 );
480 }
481
482 ui.advance_cursor_after_rect(outer_rect);
483
484 response
485}
486
487fn paint_default_icon(
488 painter: &Painter,
489 rect: Rect,
490 visuals: &WidgetVisuals,
491 above_or_below: AboveOrBelow,
492) {
493 let rect = Rect::from_center_size(
494 rect.center(),
495 vec2(rect.width() * 0.7, rect.height() * 0.45),
496 );
497
498 match above_or_below {
499 AboveOrBelow::Above => {
500 painter.add(Shape::convex_polygon(
502 vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
503 visuals.fg_stroke.color,
504 Stroke::NONE,
505 ));
506 }
507 AboveOrBelow::Below => {
508 painter.add(Shape::convex_polygon(
510 vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
511 visuals.fg_stroke.color,
512 Stroke::NONE,
513 ));
514 }
515 }
516}