From c732ecd5b07b6bf24e88be1fe203bb921c891141 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:46:46 +0200 Subject: [PATCH] Dropdowns: press, drag onto an option and release to choose it (#2735) (#2854) A dropdown needed two clicks: egui's ComboBox opens its list on a whole click and its rows only take clicks, so press-drag-release (as the menus do since #775) chose nothing. press_menu::PressCombo gives combo boxes the gesture without replacing egui's widget: - after the box, a primary press on it opens the closed list (egui counts a popup opened in a pass as open, so it is drawn from the next frame); - a row released on by that press-drag is chosen and closes the list; - on the click that ends the press (pointer still on the box), egui would toggle the list shut, so it is closed just before the box and egui's toggle reopens it, drawn that frame: a plain click still leaves the list open for a second click. widgets::dropdown (and its hovered/wheel variants), dropdown_with_tooltips, the font family picker, the kerning presets, the Gradient Map presets, the shape stroke choices and the credits sort all use it. Co-authored-by: lloyd Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/adjust_editors.rs | 18 ++- crates/ui-egui/src/credits.rs | 7 +- crates/ui-egui/src/dropdown_press_tests.rs | 76 +++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/press_menu.rs | 179 +++++++++++++++++++++ crates/ui-egui/src/shape_stroke_ui.rs | 18 ++- crates/ui-egui/src/type_tool.rs | 28 +++- crates/ui-egui/src/widgets.rs | 92 ++++++----- 8 files changed, 357 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/dropdown_press_tests.rs diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 5ce9d1bd6..53964d893 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -1147,9 +1147,14 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { ui.horizontal(|ui| { label(ui, tl!("Preset:")); let mut picked = None; - egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")).selected_text(tl!("Custom")).width(190.0).height(420.0).icon(widgets::chevron_icon).show_ui( - ui, - |ui| { + let (press, close) = crate::press_menu::PressCombo::before(ui, cx.mem.with("gm-preset")); + let combo = egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")) + .selected_text(tl!("Custom")) + .width(190.0) + .height(420.0) + .icon(widgets::chevron_icon) + .close_behavior(close) + .show_ui(ui, |ui| { for g in &cx.gradients { ui.label(RichText::new(&g.name).color(t.text_faint)); for p in &g.items { @@ -1159,14 +1164,15 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let (r, swatch) = ui.allocate_exact_size(vec2(48.0, 16.0), Sense::click()); crate::preset_panels::paint_gradient(ui, r, &rgba); // Both, not short-circuited: the name is drawn either way. - if swatch.clicked() | ui.selectable_label(false, &p.name).clicked() { + let name = ui.selectable_label(false, &p.name); + if press.chosen(ui, &swatch) | press.chosen(ui, &name) { picked = Some(rgba.iter().map(|(at, c)| (*at, [c[0], c[1], c[2]])).collect::>()); } }); } } - }, - ); + }); + press.after(&combo.response); if let Some(p) = picked { stops = p; write_stops(v, &stops); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 1d45861c8..b31aa4057 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -265,14 +265,17 @@ fn contributors_in(app: &mut PhotocraftApp, ui: &mut egui::Ui, contributors: &[C } ui.separator(); ui.label(tl!("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let (press, close) = crate::press_menu::PressCombo::before(ui, "credits_sort"); + let sort = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).close_behavior(close).show_ui(ui, |ui| { for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { + let item = ui.selectable_label(v.key == k, k.label().0); + if press.chosen(ui, &item) { v.key = k; v.ascending = k.default_ascending(); } } }); + press.after(&sort.response); if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; } diff --git a/crates/ui-egui/src/dropdown_press_tests.rs b/crates/ui-egui/src/dropdown_press_tests.rs new file mode 100644 index 000000000..c9d87116c --- /dev/null +++ b/crates/ui-egui/src/dropdown_press_tests.rs @@ -0,0 +1,76 @@ +//! #2735: a dropdown takes one press-drag-release, like the menus (#775): press the box, drag onto +//! an option, release to choose it. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +fn app_with_a_layer() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + // No tool options bar Mode dropdown showing "Normal" too. + app.ui.tool = Tool::Move; + app + }); + h.run_steps(6); + h +} + +fn blend(h: &Harness<'_, PhotocraftApp>) -> BlendMode { + let st = h.state().session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().blend +} + +/// The Layers panel's Blend Mode dropdown (showing `value`). +fn blend_box(h: &Harness<'_, PhotocraftApp>, value: &str) -> egui::Rect { + let named = |n: &egui_kittest::Node<'_>| { + let a = n.accesskit_node(); + a.value().as_deref() == Some(value) || a.label().as_deref() == Some(value) + }; + h.query_all_by_role(Role::ComboBox).find(|n| named(n)).expect("the Layers panel's Blend Mode dropdown").rect() +} + +#[test] +fn press_drag_release_picks_a_blend_mode() { + let mut h = app_with_a_layer(); + assert_eq!(blend(&h), BlendMode::Normal); + let at = blend_box(&h, "Normal").center(); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(3); + let multiply = h.query_by_label("Multiply").expect("the list opened on the press").rect().center(); + for k in 1..=6 { + h.hover_at(at + (multiply - at) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(multiply); + h.run_steps(3); + assert_eq!(blend(&h), BlendMode::Multiply, "releasing on Multiply picked it"); + assert!(h.query_by_label("Color Burn").is_none(), "and closed the list"); +} + +#[test] +fn a_click_still_opens_the_list_for_a_second_click() { + let mut h = app_with_a_layer(); + let at = blend_box(&h, "Normal").center(); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert_eq!(blend(&h), BlendMode::Normal, "the click chose nothing"); + let screen = h.query_by_label("Screen").expect("the list stays open after a click"); + screen.click(); + h.run_steps(3); + assert_eq!(blend(&h), BlendMode::Screen, "a second click picks"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 85255bdd2..0b84ada00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2159,6 +2159,9 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod dropdown_press_tests; + #[cfg(test)] mod marquee_tests; diff --git a/crates/ui-egui/src/press_menu.rs b/crates/ui-egui/src/press_menu.rs index 5654c81cc..9f124afe9 100644 --- a/crates/ui-egui/src/press_menu.rs +++ b/crates/ui-egui/src/press_menu.rs @@ -84,6 +84,63 @@ pub fn show(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R shown } +/// The same press-drag-release for an `egui::ComboBox` (#2735): the list opens on the press, so one +/// gesture can press the box, drag onto an option and release to choose it, and a plain click +/// still opens the list for a second click. egui's combo box shows its own list and toggles it on +/// a whole click, so this works around it: [`PressCombo::before`] runs before the box (and gives +/// it its close behaviour), [`PressCombo::chosen`] picks its options, [`PressCombo::after`] runs +/// after it. +pub struct PressCombo { + popup: egui::Id, +} + +impl PressCombo { + /// Before showing the combo box made with `ComboBox::from_id_salt(salt)` in `ui`; returns the + /// close behaviour to give it. + pub fn before(ui: &Ui, salt: impl egui::AsIdSalt) -> (Self, PopupCloseBehavior) { + // egui's ids for the box (`ComboBox::show_ui`) and its list (`widget_to_popup_id`). + let combo = PressCombo { popup: ui.make_persistent_id(egui::IdSalt::new(salt)).with("popup") }; + let ctx = ui.ctx(); + // The click ending the press that opened the list (the pointer didn't leave the box): egui + // toggles the list on it, so close it first and the toggle leaves it open, drawn this + // frame. No click may close it either. + if combo.owns_gesture(ctx) && ctx.input(|i| i.pointer.primary_clicked()) { + Popup::close_id(ctx, combo.popup); + return (combo, PopupCloseBehavior::IgnoreClicks); + } + (combo, PopupCloseBehavior::CloseOnClick) + } + + fn owns_gesture(&self, ctx: &egui::Context) -> bool { + gesture_owner(ctx) == Some(self.popup) + } + + /// Was option `item` (in the open list `ui`) chosen this frame: clicked, or the press-drag + /// gesture that opened the list released on it? The list closes when it was. + pub fn chosen(&self, ui: &Ui, item: &Response) -> bool { + let released_on = item.enabled() && item.contains_pointer() && ui.input(|i| i.pointer.primary_released()) && self.owns_gesture(ui.ctx()); + if released_on { + ui.close(); + } + item.clicked() || released_on + } + + /// After the combo box (`button`: its response). A primary press on the box opens a closed + /// list, starting a press-drag gesture; the gesture ends with the button. + pub fn after(&self, button: &Response) { + let ctx = &button.ctx; + let pressed = ctx.input(|i| i.pointer.primary_pressed()) && button.is_pointer_button_down_on(); + if pressed && !Popup::is_id_open(ctx, self.popup) { + // Opened now, the list counts as open this frame and egui shows it from the next. + Popup::open_id(ctx, self.popup); + ctx.data_mut(|d| d.insert_temp(gesture_id(), self.popup)); + ctx.request_repaint(); + } else if self.owns_gesture(ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(gesture_id())); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -241,4 +298,126 @@ mod tests { assert_eq!(rig.press(stroke, false).chosen, Some("Stroke"), "releasing on Stroke chose it"); assert!(!rig.frame(Vec::new()).open, "and closed the menu"); } + + /// A bare context with one `egui::ComboBox` of three options, driven with [`PressCombo`]. + struct ComboRig { + ctx: egui::Context, + value: usize, + button: Rect, + options: Vec, + } + + const OPTIONS: [&str; 3] = ["RGB", "Red", "Green"]; + + impl ComboRig { + fn new() -> Self { + let mut rig = ComboRig { ctx: egui::Context::default(), value: 0, button: Rect::NOTHING, options: Vec::new() }; + rig.frame(Vec::new()); + rig + } + + fn frame(&mut self, events: Vec) -> Frame { + let mut out = Frame::default(); + let mut options = Vec::new(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0)); + let mut value = self.value; + let mut button = Rect::NOTHING; + let mut output = self.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }, |ui| { + ui.add_space(20.0); + let (combo, close) = PressCombo::before(ui, "channel"); + let shown = egui::ComboBox::from_id_salt("channel").selected_text(OPTIONS[value]).close_behavior(close).show_ui(ui, |ui| { + out.open = true; + for (i, label) in OPTIONS.into_iter().enumerate() { + let item = ui.selectable_label(value == i, label); + options.push(item.rect); + if combo.chosen(ui, &item) { + value = i; + out.chosen = Some(label); + } + } + }); + combo.after(&shown.response); + button = shown.response.rect; + }); + output.textures_delta.clear(); + self.value = value; + self.button = button; + if !options.is_empty() { + self.options = options; + } + out + } + + fn press(&mut self, pos: Pos2, pressed: bool) -> Frame { + self.frame(vec![Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]) + } + + fn drag_to(&mut self, from: Pos2, to: Pos2) { + for k in 1..=8 { + self.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))]); + } + } + } + + #[test] + fn press_drag_release_on_a_combo_box_chooses_an_option() { + let mut rig = ComboRig::new(); + let at = rig.button.center(); + rig.frame(vec![Event::PointerMoved(at)]); + rig.press(at, true); + assert!(rig.frame(Vec::new()).open, "the list opens on the press"); + // its first frame only sizes it: the options are where they are drawn from the next + rig.frame(Vec::new()); + let red = rig.options[1].center(); + rig.drag_to(at, red); + let f = rig.press(red, false); + assert_eq!((f.chosen, rig.value), (Some("Red"), 1), "releasing on Red chose it"); + assert!(!rig.frame(Vec::new()).open, "and closed the list"); + } + + #[test] + fn a_click_on_a_combo_box_leaves_its_list_open_for_a_second_click() { + let mut rig = ComboRig::new(); + let at = rig.button.center(); + rig.frame(vec![Event::PointerMoved(at)]); + rig.press(at, true); + let f = rig.press(at, false); + assert!(f.open && f.chosen.is_none(), "the click's release keeps the list drawn and chooses nothing"); + assert!(rig.frame(Vec::new()).open, "the list stays open"); + let green = rig.options[2].center(); + rig.frame(vec![Event::PointerMoved(green)]); + rig.press(green, true); + assert_eq!(rig.press(green, false).chosen, Some("Green"), "a second click chooses"); + assert_eq!(rig.value, 2); + assert!(!rig.frame(Vec::new()).open); + } + + #[test] + fn releasing_off_a_combo_box_list_chooses_nothing() { + let mut rig = ComboRig::new(); + let at = rig.button.center(); + rig.frame(vec![Event::PointerMoved(at)]); + rig.press(at, true); + rig.frame(Vec::new()); + rig.frame(Vec::new()); + let away = pos2(380.0, 380.0); + rig.drag_to(at, away); + let f = rig.press(away, false); + assert!(f.chosen.is_none() && rig.value == 0, "a release off the options chooses nothing"); + assert!(rig.frame(Vec::new()).open, "and keeps the list open"); + } + + #[test] + fn clicking_a_combo_box_again_closes_its_list() { + let mut rig = ComboRig::new(); + let at = rig.button.center(); + rig.frame(vec![Event::PointerMoved(at)]); + rig.press(at, true); + rig.press(at, false); + assert!(rig.frame(Vec::new()).open); + rig.press(at, true); + rig.press(at, false); + assert!(!rig.frame(Vec::new()).open, "a second click on the box closes the list"); + assert_eq!(rig.value, 0); + } } diff --git a/crates/ui-egui/src/shape_stroke_ui.rs b/crates/ui-egui/src/shape_stroke_ui.rs index efae5146d..d5a0c42cf 100644 --- a/crates/ui-egui/src/shape_stroke_ui.rs +++ b/crates/ui-egui/src/shape_stroke_ui.rs @@ -311,14 +311,20 @@ pub fn button( fn choice(ui: &mut egui::Ui, label: &str, value: &mut T, choices: &[(T, &str)]) { ui.horizontal(|ui| { ui.label(tl!(label)); - egui::ComboBox::from_id_salt(label).width(140.0).selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n))).show_ui( - ui, - |ui| { + let (press, close) = crate::press_menu::PressCombo::before(ui, label); + let combo = egui::ComboBox::from_id_salt(label) + .width(140.0) + .selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n))) + .close_behavior(close) + .show_ui(ui, |ui| { for (v, n) in choices { - ui.selectable_value(value, *v, tl!(*n)); + let item = ui.selectable_label(*value == *v, tl!(*n)); + if press.chosen(ui, &item) { + *value = *v; + } } - }, - ); + }); + press.after(&combo.response); }); } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index aaec963dc..aa11ac5b2 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -977,6 +977,8 @@ pub(crate) fn family_picker_in( ) -> bool { let mut changed = false; let search_id = ui.id().with(("font-search", salt)); + // The list opens on the press, so a press-drag-release picks a font too (#2735). + let (press, close) = crate::press_menu::PressCombo::before(ui, salt); let combo = egui::ComboBox::from_id_salt(salt) .selected_text(current.as_str()) .width(width) @@ -984,7 +986,7 @@ pub(crate) fn family_picker_in( .icon(crate::widgets::chevron_icon) // Clicks inside the menu (the search field, the scroll bar) must not close it (#1369); // picking a font closes it explicitly below. - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + .close_behavior(if matches!(close, egui::PopupCloseBehavior::IgnoreClicks) { close } else { egui::PopupCloseBehavior::CloseOnClickOutside }); let response = combo.show_ui(ui, |ui| { let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height")); @@ -1054,7 +1056,7 @@ pub(crate) fn family_picker_in( if response.hovered() { hovered = Some(f.clone()); } - if response.clicked() { + if press.chosen(ui, &response) { *current = f.clone(); // A served family not fetched yet: start now, before any text needs it. photocraft_text::served::request(f); @@ -1075,6 +1077,7 @@ pub(crate) fn family_picker_in( } if accept { None } else { hovered } }); + press.after(&response.response); let hovered_font = response.inner.unwrap_or_default(); if salt == "type-font" { @@ -1397,13 +1400,22 @@ fn kerning_field(ui: &mut egui::Ui, shown: &str, width: f32) -> Option(ui: &mut Ui, id: &str, current: &mut T, o // after a step over the button. let reveal_id = wheel_id.with("reveal"); let mut reveal = wheel && ui.data_mut(|d| d.remove_temp::(reveal_id)).unwrap_or(false); - let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); - // Over the open list the wheel steps the value too, instead of scrolling the list. - over_list = wheel && ui.rect_contains_pointer(ui.clip_rect()); - if over_list { - let stepped = wheel_steps(ui, wheel_id, true, current, options); - wheeled = !stepped.is_empty(); - reveal |= wheeled; - chosen.extend(stepped); - } - // The row nearest the pointer, by its distance to each row. - let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); - let mut nearest: Option<(f32, &T)> = None; - for (v, l) in options { - let item = ui.selectable_label(v == current, tl!(l)); - if reveal && v == current { - item.scroll_to_me(None); + // The list opens on the press: press, drag onto an option and release to choose it (#2735). + let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let response = + egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l)| tl!(l))); + // Over the open list the wheel steps the value too, instead of scrolling the list. + over_list = wheel && ui.rect_contains_pointer(ui.clip_rect()); + if over_list { + let stepped = wheel_steps(ui, wheel_id, true, current, options); + wheeled = !stepped.is_empty(); + reveal |= wheeled; + chosen.extend(stepped); } - if item.hovered() { - hovered = Some(v.clone()); - } - if let Some(p) = pointer { - let d = (item.rect.top() - p.y).max(p.y - item.rect.bottom()).max(0.0); - if nearest.is_none_or(|(n, _)| d < n) { - nearest = Some((d, v)); + // The row nearest the pointer, by its distance to each row. + let pointer = ui.ctx().pointer_hover_pos().filter(|_| ui.rect_contains_pointer(ui.clip_rect())); + let mut nearest: Option<(f32, &T)> = None; + for (v, l) in options { + let item = ui.selectable_label(v == current, tl!(l)); + if reveal && v == current { + item.scroll_to_me(None); + } + if item.hovered() { + hovered = Some(v.clone()); + } + if let Some(p) = pointer { + let d = (item.rect.top() - p.y).max(p.y - item.rect.bottom()).max(0.0); + if nearest.is_none_or(|(n, _)| d < n) { + nearest = Some((d, v)); + } + } + if press.chosen(ui, &item) { + *current = v.clone(); + chosen.push(v.clone()); } } - if item.clicked() { - *current = v.clone(); - chosen.push(v.clone()); + // Between two rows (item spacing) the pointer hovers neither; it is still over the list, + // so keep previewing the nearest row instead of flashing the current value back (#2553). + if hovered.is_none() { + hovered = nearest.map(|(_, v)| v.clone()); } - } - // Between two rows (item spacing) the pointer hovers neither; it is still over the list, - // so keep previewing the nearest row instead of flashing the current value back (#2553). - if hovered.is_none() { - hovered = nearest.map(|(_, v)| v.clone()); - } - }); + }); + press.after(&response.response); if combo_box_arrow_keys(ui, &response.response, current, options) { chosen.push(current.clone()); } @@ -1140,15 +1144,19 @@ pub fn swatch_popup(swatch: &Response) -> egui::Popup<'static> { pub fn dropdown_with_tooltips(ui: &mut Ui, id: &str, current: &mut T, options: &[(T, &str, &str)], width: f32) -> bool { let label = options.iter().find(|(v, _, _)| v == current).map(|(_, l, _)| tl!(l)).unwrap_or("—"); let mut changed = false; - let response = egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).show_ui(ui, |ui| { - fit_widest_option(ui, options.iter().map(|(_, l, _)| tl!(l))); - for (v, l, tip) in options { - if ui.selectable_label(v == current, tl!(l)).on_hover_text(tl!(tip)).clicked() { - *current = v.clone(); - changed = true; + let (press, close) = crate::press_menu::PressCombo::before(ui, id); + let response = + egui::ComboBox::from_id_salt(id).selected_text(label).width(width).height(420.0).icon(chevron_icon).close_behavior(close).show_ui(ui, |ui| { + fit_widest_option(ui, options.iter().map(|(_, l, _)| tl!(l))); + for (v, l, tip) in options { + let item = ui.selectable_label(v == current, tl!(l)).on_hover_text(tl!(tip)); + if press.chosen(ui, &item) { + *current = v.clone(); + changed = true; + } } - } - }); + }); + press.after(&response.response); if let Some((_, _, tip)) = options.iter().find(|(v, _, _)| v == current) { let _ = response.response.on_hover_text(tl!(tip)); }