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 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 07:46:46 -04:00
1 parent a030d19437
commit c732ecd5b0
8 files changed
+357 -64

No files matched your search

+12 -6
View File
@@ -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::<Vec<Stop>>());
}
});
}
}
},
);
});
press.after(&combo.response);
if let Some(p) = picked {
stops = p;
write_stops(v, &stops);
+5 -2
View File
@@ -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;
}
@@ -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");
}
+3
View File
@@ -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;
+179
View File
@@ -84,6 +84,63 @@ pub fn show<R>(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::<egui::Id>(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<Rect>,
}
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<Event>) -> 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);
}
}
+12 -6
View File
@@ -311,14 +311,20 @@ pub fn button(
fn choice<T: Copy + PartialEq>(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);
});
}
+20 -8
View File
@@ -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<serde_jso
out = parse_kerning(&buf);
}
let presets = ["Metrics", "Optical", "0", "-100", "-75", "-50", "-25", "-10", "-5", "5", "10", "25", "50", "75", "100", "200"];
egui::ComboBox::from_id_salt("props-kern-presets").selected_text("").width(16.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
for p in presets {
if ui.selectable_label(p == shown, p).clicked() {
out = parse_kerning(p);
let (press, close) = crate::press_menu::PressCombo::before(ui, "props-kern-presets");
let combo = egui::ComboBox::from_id_salt("props-kern-presets")
.selected_text("")
.width(16.0)
.height(420.0)
.icon(crate::widgets::chevron_icon)
.close_behavior(close)
.show_ui(ui, |ui| {
for p in presets {
let item = ui.selectable_label(p == shown, p);
if press.chosen(ui, &item) {
out = parse_kerning(p);
}
}
}
});
});
press.after(&combo.response);
});
out
}
+50 -42
View File
@@ -956,44 +956,48 @@ fn dropdown_with<T: PartialEq + Clone>(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::<bool>(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<T: PartialEq + Clone>(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));
}