1use epaint::Shape;
2
3use crate::{style::WidgetVisuals, *};
4
5#[allow(unused_imports)] use crate::style::Spacing;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum AboveOrBelow {
11 Above,
12 Below,
13}
14
15pub type IconPainter = Box<dyn FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow)>;
17
18#[must_use = "You should call .show*"]
36pub struct ComboBox {
37 id_source: Id,
38 label: Option<WidgetText>,
39 selected_text: WidgetText,
40 width: Option<f32>,
41 height: Option<f32>,
42 icon: Option<IconPainter>,
43 wrap_mode: Option<TextWrapMode>,
44}
45
46impl ComboBox {
47 pub fn new(id_source: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
49 Self {
50 id_source: Id::new(id_source),
51 label: Some(label.into()),
52 selected_text: Default::default(),
53 width: None,
54 height: None,
55 icon: None,
56 wrap_mode: None,
57 }
58 }
59
60 pub fn from_label(label: impl Into<WidgetText>) -> Self {
62 let label = label.into();
63 Self {
64 id_source: Id::new(label.text()),
65 label: Some(label),
66 selected_text: Default::default(),
67 width: None,
68 height: None,
69 icon: None,
70 wrap_mode: None,
71 }
72 }
73
74 pub fn from_id_source(id_source: impl std::hash::Hash) -> Self {
76 Self {
77 id_source: Id::new(id_source),
78 label: Default::default(),
79 selected_text: Default::default(),
80 width: None,
81 height: None,
82 icon: None,
83 wrap_mode: None,
84 }
85 }
86
87 #[inline]
91 pub fn width(mut self, width: f32) -> Self {
92 self.width = Some(width);
93 self
94 }
95
96 #[inline]
100 pub fn height(mut self, height: f32) -> Self {
101 self.height = Some(height);
102 self
103 }
104
105 #[inline]
107 pub fn selected_text(mut self, selected_text: impl Into<WidgetText>) -> Self {
108 self.selected_text = selected_text.into();
109 self
110 }
111
112 pub fn icon(
144 mut self,
145 icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool, AboveOrBelow) + 'static,
146 ) -> Self {
147 self.icon = Some(Box::new(icon_fn));
148 self
149 }
150
151 #[inline]
157 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
158 self.wrap_mode = Some(wrap_mode);
159 self
160 }
161
162 #[inline]
164 pub fn wrap(mut self) -> Self {
165 self.wrap_mode = Some(TextWrapMode::Wrap);
166
167 self
168 }
169
170 #[inline]
172 pub fn truncate(mut self) -> Self {
173 self.wrap_mode = Some(TextWrapMode::Truncate);
174 self
175 }
176
177 pub fn show_ui<R>(
181 self,
182 ui: &mut Ui,
183 menu_contents: impl FnOnce(&mut Ui) -> R,
184 ) -> InnerResponse<Option<R>> {
185 self.show_ui_dyn(ui, Box::new(menu_contents))
186 }
187
188 fn show_ui_dyn<'c, R>(
189 self,
190 ui: &mut Ui,
191 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
192 ) -> InnerResponse<Option<R>> {
193 let Self {
194 id_source,
195 label,
196 selected_text,
197 width,
198 height,
199 icon,
200 wrap_mode,
201 } = self;
202
203 let button_id = ui.make_persistent_id(id_source);
204
205 ui.horizontal(|ui| {
206 let mut ir = combo_box_dyn(
207 ui,
208 button_id,
209 selected_text,
210 menu_contents,
211 icon,
212 wrap_mode,
213 (width, height),
214 );
215 if let Some(label) = label {
216 ir.response.widget_info(|| {
217 WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text())
218 });
219 ir.response |= ui.label(label);
220 } else {
221 ir.response
222 .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), ""));
223 }
224 ir
225 })
226 .inner
227 }
228
229 pub fn show_index<Text: Into<WidgetText>>(
248 self,
249 ui: &mut Ui,
250 selected: &mut usize,
251 len: usize,
252 get: impl Fn(usize) -> Text,
253 ) -> Response {
254 let slf = self.selected_text(get(*selected));
255
256 let mut changed = false;
257
258 let mut response = slf
259 .show_ui(ui, |ui| {
260 for i in 0..len {
261 if ui.selectable_label(i == *selected, get(i)).clicked() {
262 *selected = i;
263 changed = true;
264 }
265 }
266 })
267 .response;
268
269 if changed {
270 response.mark_changed();
271 }
272 response
273 }
274
275 pub fn is_open(ctx: &Context, id: Id) -> bool {
277 ctx.memory(|m| m.is_popup_open(Self::widget_to_popup_id(id)))
278 }
279
280 fn widget_to_popup_id(widget_id: Id) -> Id {
282 widget_id.with("popup")
283 }
284}
285
286#[allow(clippy::too_many_arguments)]
287fn combo_box_dyn<'c, R>(
288 ui: &mut Ui,
289 button_id: Id,
290 selected_text: WidgetText,
291 menu_contents: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
292 icon: Option<IconPainter>,
293 wrap_mode: Option<TextWrapMode>,
294 (width, height): (Option<f32>, Option<f32>),
295) -> InnerResponse<Option<R>> {
296 let popup_id = ComboBox::widget_to_popup_id(button_id);
297
298 let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id));
299
300 let popup_height = ui.memory(|m| {
301 m.areas()
302 .get(popup_id)
303 .and_then(|state| state.size)
304 .map_or(100.0, |size| size.y)
305 });
306
307 let above_or_below =
308 if ui.next_widget_position().y + ui.spacing().interact_size.y + popup_height
309 < ui.ctx().screen_rect().bottom()
310 {
311 AboveOrBelow::Below
312 } else {
313 AboveOrBelow::Above
314 };
315
316 let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode());
317
318 let margin = ui.spacing().button_padding;
319 let button_response = button_frame(ui, button_id, is_popup_open, Sense::click(), |ui| {
320 let icon_spacing = ui.spacing().icon_spacing;
321 let icon_size = Vec2::splat(ui.spacing().icon_width);
322
323 let minimum_width = width.unwrap_or_else(|| ui.spacing().combo_width) - 2.0 * margin.x;
327
328 let wrap_width = if wrap_mode == TextWrapMode::Extend {
330 f32::INFINITY
332 } else {
333 ui.available_width() - icon_spacing - icon_size.x
335 };
336
337 let galley = selected_text.into_galley(ui, Some(wrap_mode), wrap_width, TextStyle::Button);
338
339 let actual_width = (galley.size().x + icon_spacing + icon_size.x).at_least(minimum_width);
340 let actual_height = galley.size().y.max(icon_size.y);
341
342 let (_, rect) = ui.allocate_space(Vec2::new(actual_width, actual_height));
343 let button_rect = ui.min_rect().expand2(ui.spacing().button_padding);
344 let response = ui.interact(button_rect, button_id, Sense::click());
345 if ui.is_rect_visible(rect) {
348 let icon_rect = Align2::RIGHT_CENTER.align_size_within_rect(icon_size, rect);
349 let visuals = if is_popup_open {
350 &ui.visuals().widgets.open
351 } else {
352 ui.style().interact(&response)
353 };
354
355 if let Some(icon) = icon {
356 icon(
357 ui,
358 icon_rect.expand(visuals.expansion),
359 visuals,
360 is_popup_open,
361 above_or_below,
362 );
363 } else {
364 paint_default_icon(
365 ui.painter(),
366 icon_rect.expand(visuals.expansion),
367 visuals,
368 above_or_below,
369 );
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 if button_response.clicked() {
379 ui.memory_mut(|mem| mem.toggle_popup(popup_id));
380 }
381
382 let height = height.unwrap_or_else(|| ui.spacing().combo_height);
383
384 let inner = crate::popup::popup_above_or_below_widget(
385 ui,
386 popup_id,
387 &button_response,
388 above_or_below,
389 PopupCloseBehavior::CloseOnClick,
390 |ui| {
391 ScrollArea::vertical()
392 .max_height(height)
393 .show(ui, |ui| {
394 ui.style_mut().wrap_mode = Some(TextWrapMode::Extend);
400 menu_contents(ui)
401 })
402 .inner
403 },
404 );
405
406 InnerResponse {
407 inner,
408 response: button_response,
409 }
410}
411
412fn button_frame(
413 ui: &mut Ui,
414 id: Id,
415 is_popup_open: bool,
416 sense: Sense,
417 add_contents: impl FnOnce(&mut Ui),
418) -> Response {
419 let where_to_put_background = ui.painter().add(Shape::Noop);
420
421 let margin = ui.spacing().button_padding;
422 let interact_size = ui.spacing().interact_size;
423
424 let mut outer_rect = ui.available_rect_before_wrap();
425 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
426
427 let inner_rect = outer_rect.shrink2(margin);
428 let mut content_ui = ui.child_ui(inner_rect, *ui.layout(), None);
429 add_contents(&mut content_ui);
430
431 let mut outer_rect = content_ui.min_rect().expand2(margin);
432 outer_rect.set_height(outer_rect.height().at_least(interact_size.y));
433
434 let response = ui.interact(outer_rect, id, sense);
435
436 if ui.is_rect_visible(outer_rect) {
437 let visuals = if is_popup_open {
438 &ui.visuals().widgets.open
439 } else {
440 ui.style().interact(&response)
441 };
442
443 ui.painter().set(
444 where_to_put_background,
445 epaint::RectShape::new(
446 outer_rect.expand(visuals.expansion),
447 visuals.rounding,
448 visuals.weak_bg_fill,
449 visuals.bg_stroke,
450 ),
451 );
452 }
453
454 ui.advance_cursor_after_rect(outer_rect);
455
456 response
457}
458
459fn paint_default_icon(
460 painter: &Painter,
461 rect: Rect,
462 visuals: &WidgetVisuals,
463 above_or_below: AboveOrBelow,
464) {
465 let rect = Rect::from_center_size(
466 rect.center(),
467 vec2(rect.width() * 0.7, rect.height() * 0.45),
468 );
469
470 match above_or_below {
471 AboveOrBelow::Above => {
472 painter.add(Shape::convex_polygon(
474 vec![rect.left_bottom(), rect.right_bottom(), rect.center_top()],
475 visuals.fg_stroke.color,
476 Stroke::NONE,
477 ));
478 }
479 AboveOrBelow::Below => {
480 painter.add(Shape::convex_polygon(
482 vec![rect.left_top(), rect.right_top(), rect.center_bottom()],
483 visuals.fg_stroke.color,
484 Stroke::NONE,
485 ));
486 }
487 }
488}