//! Combo boxes and drop-down menus whose menu follows its contents' height while it is open. //! //! egui lays a popup out in the size it had on the previous pass, and [`egui::ComboBox`] puts its //! menu in a [`ScrollArea`] that takes no more than that height. Contents that shrink (a search //! with no matches) shrink the menu; contents that grow again (the search cleared) only scroll //! inside it, and the menu stays small until it is closed (emilk/egui#5225). [`ComboBox`] is //! egui's combo box with a menu that offers its contents the full height on every pass, so the //! menu grows back. [`Menu`] is that menu under a field an app draws itself. //! //! A searchable menu keeps its search field in place and scrolls only its list: //! [`ComboBox::show_ui_with_header`] and [`ComboBox::show_rows_with_header`], and the same on //! [`Menu`]. use std::cell::RefCell; use std::ops::Range; use egui::{ Align, Align2, AsIdSalt, Atom, AtomExt as _, Atoms, Context, Direction, Frame, Id, IdSalt, InnerResponse, IntoAtoms, LayerId, NumExt as _, Order, Painter, Popup, PopupCloseBehavior, Rect, RectAlign, Response, Role, ScrollArea, Sense, Shape, TextStyle, TextWrapMode, Ui, Vec2, WidgetAtom, WidgetInfo, class::{ClassName, Classes, HasClasses}, style::{StyleModifier, WidgetVisuals}, vec2, widget_style::ButtonStyle, }; pub use egui::IconPainter; /// egui's [`egui::ComboBox`] with a menu that grows when its contents do. /// /// The builder, the button and what the methods return are egui 0.37's. The one difference is /// the menu: its scroll area is offered the full [`Self::height`] on every pass instead of the /// menu's height on the previous pass, so a menu whose contents shrink and grow again while it /// is open (a filtered list) grows back. It adds [`Self::show_ui_with_header`] and /// [`Self::show_rows_with_header`] for menus with a search field. /// /// ``` /// # egui::__run_test_ui(|ui| { /// let mut selected = 0; /// craft_ui::ComboBox::from_label("Size").show_index(ui, &mut selected, 3, |i| ["Small", "Medium", "Large"][i]); /// # }); /// ``` #[must_use = "You should call .show*"] pub struct ComboBox<'a> { id_salt: IdSalt, label: Option>, selected_text: Atoms<'a>, width: Option, height: Option, icon: Option, wrap_mode: Option, close_behavior: Option, popup_style: StyleModifier, classes: Classes, } impl<'a> ComboBox<'a> { /// Present on the button of every combo box, as on egui's. #[expect(clippy::disallowed_types, reason = "egui's class names, so themes style both alike")] pub const CLASS: ClassName = egui::ComboBox::CLASS; /// Present on the button of a combo box while its menu is open, as on egui's. #[expect(clippy::disallowed_types, reason = "egui's class names, so themes style both alike")] pub const CLASS_OPEN: ClassName = egui::ComboBox::CLASS_OPEN; fn with(id_salt: IdSalt, label: Option>) -> Self { let mut combo = Self { id_salt, label, selected_text: Atoms::default(), width: None, height: None, icon: None, wrap_mode: None, close_behavior: None, popup_style: StyleModifier::default(), classes: Classes::default(), }; combo.add_class(Self::CLASS); combo } /// A combo box with `label` next to it, its id from `id_salt`. pub fn new(id_salt: impl AsIdSalt, label: impl IntoAtoms<'a>) -> Self { Self::with(IdSalt::new(id_salt), Some(label.into_atoms())) } /// A combo box with `label` next to it, its id from the label's contents. pub fn from_label(label: impl IntoAtoms<'a>) -> Self { let label = label.into_atoms(); Self::with(label.salt(), Some(label)) } /// A combo box without a label. pub fn from_id_salt(id_salt: impl AsIdSalt) -> Self { Self::with(IdSalt::new(id_salt), None) } /// The button's minimum width. Default: [`egui::Spacing::combo_width`]. #[inline] pub fn width(mut self, width: f32) -> Self { self.width = Some(width); self } /// The maximum height of the menu's contents, inside its frame. Default: /// [`egui::Spacing::combo_height`]. #[inline] pub fn height(mut self, height: f32) -> Self { self.height = Some(height); self } /// What the button shows as the selected value. #[inline] pub fn selected_text(mut self, selected_text: impl IntoAtoms<'a>) -> Self { self.selected_text = selected_text.into_atoms(); self } /// Paints the icon in place of the down-pointing triangle. #[inline] pub fn icon(mut self, icon_fn: impl FnOnce(&Ui, Rect, &WidgetVisuals, bool) + 'static) -> Self { self.icon = Some(Box::new(icon_fn)); self } /// How the selected text wraps. Default: the [`Ui`]'s. #[inline] pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self { self.wrap_mode = Some(wrap_mode); self } /// Wraps the selected text: [`TextWrapMode::Wrap`]. #[inline] pub fn wrap(mut self) -> Self { self.wrap_mode = Some(TextWrapMode::Wrap); self } /// Truncates the selected text: [`TextWrapMode::Truncate`]. #[inline] pub fn truncate(mut self) -> Self { self.wrap_mode = Some(TextWrapMode::Truncate); self } /// When the menu closes. Default: [`PopupCloseBehavior::CloseOnClick`]. #[inline] pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self { self.close_behavior = Some(close_behavior); self } /// The menu's style. Default: the [`Ui`]'s. #[inline] pub fn popup_style(mut self, popup_style: StyleModifier) -> Self { self.popup_style = popup_style; self } /// Shows the button, and `menu_contents` in the menu while it is open. `inner` is what /// `menu_contents` returned, or `None` while the menu is closed. pub fn show_ui(self, ui: &mut Ui, menu_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse> { self.show(ui, |menu| menu.show_ui(menu_contents)) } /// Like [`Self::show_ui`], with `header` (a search field, filter chips) fixed at the top of /// the menu and only `list` scrolling under it. See [`Menu::show_ui_with_header`]. pub fn show_ui_with_header( self, ui: &mut Ui, header: impl FnOnce(&mut Ui) -> H, list: impl FnOnce(&mut Ui, H) -> R, ) -> InnerResponse> { self.show(ui, |menu| menu.show_ui_with_header(header, list)) } /// Like [`Self::show_ui_with_header`], with a list of rows of one height that only lays out /// the rows in view. See [`Menu::show_rows_with_header`]. pub fn show_rows_with_header( self, ui: &mut Ui, header: impl FnOnce(&mut Ui) -> H, row_height: f32, total_rows: impl FnOnce(&H) -> usize, rows: impl FnOnce(&mut Ui, Range, H) -> R, ) -> InnerResponse> { self.show(ui, |menu| menu.show_rows_with_header(header, row_height, total_rows, rows)) } /// Like [`Self::show_ui_with_header`], with `footer` (an "Add…" row) fixed under the list. /// See [`Menu::show_ui_with_header_footer`]. pub fn show_ui_with_header_footer( self, ui: &mut Ui, header: impl FnOnce(&mut Ui) -> H, list: impl FnOnce(&mut Ui, H) -> R, footer: impl FnOnce(&mut Ui) -> F, ) -> InnerResponse> { self.show(ui, |menu| menu.show_ui_with_header_footer(header, list, footer)) } /// Like [`Self::show_rows_with_header`], with `footer` fixed under the list. See /// [`Menu::show_ui_with_header_footer`]. pub fn show_rows_with_header_footer( self, ui: &mut Ui, header: impl FnOnce(&mut Ui) -> H, row_height: f32, total_rows: impl FnOnce(&H) -> usize, rows: impl FnOnce(&mut Ui, Range, H) -> R, footer: impl FnOnce(&mut Ui) -> F, ) -> InnerResponse> { self.show(ui, |menu| menu.show_rows_with_header_footer(header, row_height, total_rows, rows, footer)) } fn show(self, ui: &mut Ui, open: impl FnOnce(Menu<'_>) -> Option) -> InnerResponse> { let button_id = ui.make_persistent_id(self.id_salt); show(ui, button_id, self, open) } /// Shows `get(i)` for `i` in `0..len` as the choices, with `*selected` chosen. The response is /// marked changed when a choice is picked. pub fn show_index>(self, ui: &mut Ui, selected: &mut usize, len: usize, get: impl Fn(usize) -> Text) -> Response { let combo = self.selected_text(get(*selected)); let mut changed = false; let mut response = combo .show_ui(ui, |ui| { for i in 0..len { if ui.selectable_label(i == *selected, get(i)).clicked() { *selected = i; changed = true; } } }) .response; if changed { response.mark_changed(); } response } /// Whether the menu of the combo box `id` (its response's id) is open. pub fn is_open(ctx: &Context, id: Id) -> bool { Popup::is_id_open(ctx, popup_id(id)) } } impl HasClasses for ComboBox<'_> { fn classes(&self) -> &Classes { &self.classes } fn classes_mut(&mut self) -> &mut Classes { &mut self.classes } } fn popup_id(button_id: Id) -> Id { button_id.with("popup") } // The button is egui 0.37's `combo_box_dyn`, so a combo box looks the same as egui's. fn show(ui: &mut Ui, button_id: Id, mut combo: ComboBox<'_>, open: impl FnOnce(Menu<'_>) -> Option) -> InnerResponse> { let is_popup_open = Popup::is_id_open(ui.ctx(), popup_id(button_id)); let wrap_mode = combo.wrap_mode.unwrap_or_else(|| ui.wrap_mode()); let close_behavior = combo.close_behavior.unwrap_or(PopupCloseBehavior::CloseOnClick); combo.set_class(ComboBox::CLASS_OPEN, is_popup_open); let ComboBox { label, selected_text, width, height, icon, popup_style, classes, .. } = combo; // Built from the same atoms and style as a `Button`, so the two line up when put side by side. let ButtonStyle { atom_layout: atom_layout_style } = ui.widget_style(button_id, &classes); // The visuals passed to the icon painter, from the interaction state at the start of the pass. let visuals = if is_popup_open { ui.visuals().widgets.open } else if let Some(response) = ui.ctx().read_response(button_id) { *ui.style().interact(&response) } else { ui.visuals().widgets.inactive }; let min_width = width.unwrap_or_else(|| ui.spacing().combo_width); let min_size = Vec2::new(min_width, 0.0).at_least(atom_layout_style.min_size); let icon = RefCell::new(icon); let rtl = ui.style().right_to_left; let icon_atom = Atom::paint(Vec2::splat(ui.spacing().icon_width), move |ui, args| { let rect = args.rect.expand(visuals.expansion); if let Some(icon) = icon.borrow_mut().take() { icon(ui, rect, &visuals, is_popup_open); } else { paint_default_icon(ui.painter(), rect, &visuals); } }) .atom_grow(true) .atom_align(if rtl { Align2::LEFT_CENTER } else { Align2::RIGHT_CENTER }); let direction = if rtl { Direction::RightToLeft } else { Direction::LeftToRight }; let accessible_text = selected_text.text().map(String::from).unwrap_or_default(); let button = atom_layout_style .apply( WidgetAtom::new((selected_text, icon_atom)) .id(button_id) .sense(Sense::click()) .fallback_font(TextStyle::Button) .wrap_mode(wrap_mode) .direction(direction), ) .min_size(min_size); let (button_response, label_response) = match label { Some(label) => { let label_text = label.text().map(String::from).unwrap_or_default(); let outer_response = WidgetAtom::new((Atom::widget(button), label)).gap(ui.spacing().item_spacing.x).direction(direction).show(ui).response; outer_response.widget_info(|| WidgetInfo::labeled(Role::Label, ui.is_enabled(), &label_text)); let button_response = ui.ctx().read_response(button_id).unwrap_or_else(|| outer_response.clone()); (button_response, Some((outer_response, !label_text.is_empty()))) } None => (button.show(ui).response, None), }; button_response.widget_info(|| { let mut info = WidgetInfo::new(Role::ComboBox); info.enabled = ui.is_enabled(); info.current_text_value = Some(accessible_text.clone()); info }); let popup = Popup::menu(&button_response).id(popup_id(button_id)); let mut menu = Menu::with_popup(popup, ui, &button_response).style(popup_style).close_behavior(close_behavior); if let Some(height) = height { menu = menu.height(height); } let inner = open(menu); InnerResponse { inner, response: match label_response { // An empty label names nothing, so the caller names the combo box. Some((label_response, true)) => button_response.labelled_by(label_response.id) | label_response, Some((label_response, false)) => button_response | label_response, None => button_response, }, } } fn paint_default_icon(painter: &Painter, rect: Rect, visuals: &WidgetVisuals) { let rect = Rect::from_center_size(rect.center(), vec2(rect.width() * 0.7, rect.height() * 0.45)); painter.add(Shape::rotated_triangle(rect, 0.0, visuals.fg_stroke.color)); } /// The menu of a combo box, under a field an app draws itself: [`ComboBox`]'s menu without the /// button. A click on the field opens and closes it, or the caller does with [`Self::open`]; the /// menu is at least as wide as the field. /// /// The menu opens below the field, or above it when there is more room there, and stays on that /// side while it is open. It is never taller than the room on that side: a long list scrolls. /// /// Searchable menus use the header forms ([`Self::show_ui_with_header`] and the rest): the header /// stays in place and only the list scrolls. Anything fixed above the list goes in the header, /// such as a "none" row above the search field; anything fixed below it goes in the footer. /// [`Self::show_rows_with_header`] is for rows of one height; a list of rows of different /// heights, or a tree, uses [`Self::show_ui_with_header`]. /// /// ``` /// # egui::__run_test_ui(|ui| { /// let field = ui.button("Font"); /// craft_ui::combo::Menu::new(ui, &field).height(400.0).show_ui(|ui| ui.label("Arial")); /// # }); /// ``` #[must_use = "You should call .show*"] pub struct Menu<'a> { popup: Popup<'a>, ctx: Context, field: Rect, field_id: Id, height: f32, style: StyleModifier, close_behavior: PopupCloseBehavior, open: Option<&'a mut bool>, } impl<'a> Menu<'a> { /// The menu under `field`, a widget in `ui`. pub fn new(ui: &Ui, field: &Response) -> Self { Self::with_popup(Popup::menu(field), ui, field) } fn with_popup(popup: Popup<'a>, ui: &Ui, field: &Response) -> Self { // Below the field or above it, never beside it. let flipped: &'static [RectAlign] = if ui.ctx().global_style().right_to_left { &[RectAlign::TOP_END] } else { &[RectAlign::TOP_START] }; Self { popup: popup.align_alternatives(flipped), ctx: field.ctx.clone(), field: field.rect, field_id: field.id, height: ui.spacing().combo_height, style: StyleModifier::from(egui::containers::menu::menu_style), close_behavior: PopupCloseBehavior::CloseOnClick, open: None, } } /// The maximum height of the menu's contents, inside its frame, kept to the room on its side /// of the field. Default: [`egui::Spacing::combo_height`]. #[inline] pub fn height(mut self, height: f32) -> Self { self.height = height; self } /// When the menu closes. Default: [`PopupCloseBehavior::CloseOnClick`]. #[inline] pub fn close_behavior(mut self, close_behavior: PopupCloseBehavior) -> Self { self.close_behavior = close_behavior; self } /// Shows the menu while `*open`, for a field that isn't a button, such as a text field that /// searches the menu while it keeps the keyboard focus. The caller sets `*open` to open the /// menu, and the menu clears it when it closes: on its close behavior, Escape, /// [`Ui::close`] inside it, or the field losing the focus to anything but the menu. Clicks on /// the field neither open nor close it. The menu doesn't take the focus. /// /// ``` /// # egui::__run_test_ui(|ui| { /// # let (mut text, mut open) = (String::new(), false); /// let field = ui.text_edit_singleline(&mut text); /// open |= field.gained_focus() || field.changed(); /// craft_ui::combo::Menu::new(ui, &field).open(&mut open).show_ui(|ui| ui.label("Arial")); /// # }); /// ``` #[inline] pub fn open(mut self, open: &'a mut bool) -> Self { self.open = Some(open); self } /// The menu's style. Default: egui's menu style. #[inline] pub fn style(mut self, style: impl Into) -> Self { self.style = style.into(); self } /// Shows `add` in the menu while it is open, scrolling when it is taller than the menu. /// Returns what `add` returned, or `None` while the menu is closed. pub fn show_ui(self, add: impl FnOnce(&mut Ui) -> R) -> Option { self.show(|_| (), |scroll, ui, ()| scroll.show(ui, add).inner, None::).map(|(r, _)| r) } /// Shows `header` at the top of the menu and `list` under it while the menu is open, and /// returns what `list` returned. `list` gets what `header` returned, such as the rows a /// search field's text matches. Only `list` scrolls; the header stays in place. The menu is /// as tall as both, up to its height. `list` lays out in a vertical scroll area: give it /// rows, not a scroll area of its own. pub fn show_ui_with_header(self, header: impl FnOnce(&mut Ui) -> H, list: impl FnOnce(&mut Ui, H) -> R) -> Option { self.show(header, |scroll, ui, h| scroll.show(ui, |ui| list(ui, h)).inner, None::).map(|(r, _)| r) } /// Like [`Self::show_ui_with_header`], with a list of `total_rows(&header's)` rows, each /// `row_height` tall without the item spacing, that only lays out the rows in view: `rows` /// gets the range to add, as in [`ScrollArea::show_rows`], and what `header` returned. For /// rows of one height only. pub fn show_rows_with_header( self, header: impl FnOnce(&mut Ui) -> H, row_height: f32, total_rows: impl FnOnce(&H) -> usize, rows: impl FnOnce(&mut Ui, Range, H) -> R, ) -> Option { self.show(header, rows_list(row_height, total_rows, rows), None::).map(|(r, _)| r) } /// Like [`Self::show_ui_with_header`], with `footer` (an "Add…" row) fixed under the list. /// Returns what `list` and `footer` returned. pub fn show_ui_with_header_footer( self, header: impl FnOnce(&mut Ui) -> H, list: impl FnOnce(&mut Ui, H) -> R, footer: impl FnOnce(&mut Ui) -> F, ) -> Option<(R, F)> { self.show(header, |scroll, ui, h| scroll.show(ui, |ui| list(ui, h)).inner, Some(footer)).and_then(|(r, f)| Some((r, f?))) } /// Like [`Self::show_rows_with_header`], with `footer` fixed under the list. pub fn show_rows_with_header_footer( self, header: impl FnOnce(&mut Ui) -> H, row_height: f32, total_rows: impl FnOnce(&H) -> usize, rows: impl FnOnce(&mut Ui, Range, H) -> R, footer: impl FnOnce(&mut Ui) -> F, ) -> Option<(R, F)> { self.show(header, rows_list(row_height, total_rows, rows), Some(footer)).and_then(|(r, f)| Some((r, f?))) } fn show( self, header: impl FnOnce(&mut Ui) -> H, list: impl FnOnce(ScrollArea, &mut Ui, H) -> R, footer: Option F>, ) -> Option<(R, Option)> { let Self { popup, ctx, field, field_id, height, style, mut close_behavior, open } = self; let id = popup.get_id(); let popup = match open { Some(open) => { let focus_key = id.with("craft-field-focus"); let focused = ctx.memory(|m| m.has_focus(field_id)); let had_focus = ctx.data_mut(|d| std::mem::replace(d.get_temp_mut_or_default::(focus_key), focused)); if had_focus && !focused && !moved_into(&ctx, id) { *open = false; } // A click in the field (moving a text field's cursor) leaves the menu open. if ctx.input(|i| i.pointer.interact_pos()).is_some_and(|pos| field.contains(pos)) { close_behavior = PopupCloseBehavior::IgnoreClicks; } popup.open_bool(open) } None => popup, }; let footer_id = id.with("craft-footer-height"); let remeasure_id = id.with("craft-remeasure"); let remeasure = ctx.data_mut(|d| d.remove_temp::(remeasure_id)).unwrap_or(false); let (align, settled) = side(&popup, remeasure); // At most two remeasures in a row, so a footer that changes height on every pass settles. // A pass where the footer keeps its height clears the count. let count_id = id.with("craft-remeasures"); if ctx.read_response(id).is_none() { ctx.data_mut(|d| d.remove::(count_id)); } let remeasures = ctx.data(|d| d.get_temp::(count_id)).unwrap_or(0); // Until the side is settled, the menu is measured against the whole screen. let room = match popup.get_anchor_rect() { Some(anchor) if settled => room(align, anchor, ctx.content_rect()), _ => ctx.content_rect().height(), }; let mut popup_style = (*ctx.global_style()).clone(); style.apply(&mut popup_style); let frame = Frame::popup(&popup_style).total_margin().sum().y; let height = height.min(room - frame).max(0.0); let inner = popup .align(align) .align_alternatives(&[]) .width(field.width()) .style(style) .close_behavior(close_behavior) .show(|ui| { // The pass that measures the menu before its side is settled: drawn at a side that // may change and at a height the room doesn't cap. Laid out and interactive (a // search field takes the focus), but not seen. if remeasure && !settled { ui.multiply_opacity(0.0); } ui.set_min_width(ui.available_width()); // The popup offers last pass's height. Offer the full height instead: the scroll // area takes what the header and footer leave of it, then shrinks to the list. ui.set_max_height(height); let header = header(ui); // The footer comes after the list; keep the room it took last pass. let footer_height = ui.data(|d| d.get_temp::(footer_id)); let footer_room = if footer.is_some() { footer_height.unwrap_or(0.0) + ui.spacing().item_spacing.y } else { 0.0 }; // Labels widen a narrow menu rather than wrap early, as in egui's combo box. A // list left with no room keeps ScrollArea's minimum scrolled height. ui.style_mut().wrap_mode = Some(TextWrapMode::Extend); let list = list(ScrollArea::vertical().max_height((ui.available_height() - footer_room).max(0.0)), ui, header); let footer = footer.map(|footer| { let top = ui.cursor().top(); let f = footer(ui); let height = ui.min_rect().bottom() - top; let changed = footer_height.is_none_or(|h| (h - height).abs() > 0.5); if !changed && remeasures > 0 { ui.data_mut(|d| d.remove::(count_id)); } if changed { // Recorded even past the cap, so "unchanged" means unchanged since the last pass. ui.data_mut(|d| d.insert_temp(footer_id, height)); } if changed && remeasures < 2 { // The list took the wrong room: lay the menu out again next pass. Not a // discarded pass: the header's side effects (focusing a search field on // open) would run twice. ui.data_mut(|d| { d.insert_temp(remeasure_id, true); d.insert_temp(count_id, remeasures + 1); }); ui.ctx().request_repaint(); } f }); (list, footer) }) .map(|r| r.inner); if inner.is_some() { ctx.accesskit_node_builder(id, |node| node.set_role(Role::ListBox)); } inner } } fn rows_list( row_height: f32, total_rows: impl FnOnce(&H) -> usize, rows: impl FnOnce(&mut Ui, Range, H) -> R, ) -> impl FnOnce(ScrollArea, &mut Ui, H) -> R { move |scroll, ui, h| { let total = total_rows(&h); scroll.show_rows(ui, row_height, total, |ui, range| rows(ui, range, h)).inner } } /// Whether the pointer or the keyboard focus went into the menu `popup` this pass. fn moved_into(ctx: &Context, popup: Id) -> bool { // The pointer counts only on a press or click: a resting mouse says nothing about focus. let pointer = ctx.input(|i| (i.pointer.any_pressed() || i.pointer.any_click()).then(|| i.pointer.interact_pos())).flatten(); let in_menu = ctx.memory(|m| m.area_rect(popup)).zip(pointer).is_some_and(|(menu, pos)| menu.contains(pos)); let focused = ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)); in_menu || focused.is_some_and(|r| r.layer_id == LayerId::new(Order::Foreground, popup)) } /// The side of its field `popup` shows on, and whether it is settled. The side is settled on the /// first pass after the menu was shown and measured (`remeasure` when that measure is stale): the /// side egui picks when the menu fits there, else the one with more room. It is kept while the /// menu stays open, so a menu that shrinks or grows while open doesn't jump to the other side. fn side(popup: &Popup<'_>, remeasure: bool) -> (RectAlign, bool) { let ctx = popup.ctx(); let key = popup.get_id().with("craft-side"); if !popup.is_open() || ctx.read_response(popup.get_id()).is_none() { ctx.data_mut(|d| d.remove::(key)); return (popup.get_best_align(), false); } if let Some(align) = ctx.data(|d| d.get_temp::(key)) { return (align, true); } if remeasure { return (popup.get_best_align(), false); } // Outside the data lock: these read the popup's state from memory. let mut align = popup.get_best_align(); if let (Some(size), Some(anchor)) = (popup.get_expected_size(), popup.get_anchor_rect()) { let screen = ctx.content_rect(); let flipped = align.flipped_y(); if room(align, anchor, screen) < size.y && room(flipped, anchor, screen) > room(align, anchor, screen) { align = flipped; } } ctx.data_mut(|d| d.insert_temp(key, align)); (align, true) } /// The height a menu on the `align` side of `anchor` has inside `screen`. fn room(align: RectAlign, anchor: Rect, screen: Rect) -> f32 { match (align.parent.y(), align.child.y()) { (Align::Max, Align::Min) => screen.bottom() - anchor.bottom(), (Align::Min, Align::Max) => anchor.top() - screen.top(), _ => screen.height(), } } #[cfg(test)] mod tests;