diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index fa5f5c54..cad1081e 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -179,6 +179,9 @@ pub struct MenuState { pub over_geometry: bool, /// The plane under the cursor when the viewport menu opened: it offers a sketch on it. pub plane: Option, + /// The button that opened the menu, when it toggles it: a press there is not a press outside, + /// so its click closes the menu instead of closing and reopening it (#1522). + opener: Option, } #[derive(Clone, Debug, PartialEq)] @@ -1349,12 +1352,24 @@ pub fn open_for(app: &mut SolveApp, at: Pos2, target: Target) { app.menu.radial = target == Target::Viewport && app.preferences.radial_context_menu; app.menu.target = target; app.menu.flick = false; + app.menu.opener = None; app.viewport.context_menu = Some(at); } +/// A button's menu: a click opens it, and a click on the same button while it is open closes it. +pub fn toggle_for(app: &mut SolveApp, opener: Rect, at: Pos2, target: Target) { + if app.viewport.context_menu.is_some() && app.menu.target == target && app.menu.opener == Some(opener) { + close(app); + } else { + open_for(app, at, target); + app.menu.opener = Some(opener); + } +} + pub fn close(app: &mut SolveApp) { app.viewport.context_menu = None; app.menu.flick = false; + app.menu.opener = None; } /// Open the menu at a point for automation: the pointer moves there first so what is under it @@ -1557,8 +1572,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let target = app.menu.target.clone(); let list = items(app, &target); let mut picked: Option = None; - let mut inside = false; let pointer = ctx.input(|i| i.pointer.latest_pos()); + let mut inside = app.menu.opener.is_some_and(|r| pointer.is_some_and(|p| r.contains(p))); let list_at = if app.menu.radial { let c = radial_center(at, screen); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c414ceec..effc2bfe 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -582,7 +582,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text); painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, app.tr("ui.workspace"), FontId::proportional(10.0), t.text_dim); if wresp.on_hover_text(app.tr("ui.change_workspace")).clicked() { - crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace); + crate::context_menu::toggle_for(app, ws, ws.left_bottom(), crate::context_menu::Target::Workspace); } crate::scenario::publish_rect("toolbar", r); crate::scenario::publish_rect("workspace_switcher", ws); @@ -830,8 +830,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let gy = lr.center().y - g.size().y / 2.0; painter.galley(pos2(gx, gy), g, lc); caret(&painter, pos2(gx + gw / 2.0 + 6.0, lr.center().y), lc); + // The label toggles its drop-down (#1522). if lresp.clicked() { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_panel_open"), popup_id)); + ui.ctx().data_mut(|d| { + let key = egui::Id::new("sc_panel_open"); + if d.get_temp::(key) == Some(popup_id) { + d.remove::(key); + } else { + d.insert_temp(key, popup_id); + } + }); } // Labels, gaps and margins append; actual buttons keep their insert-before target. // Respect the current layer so a dropdown covering the panel is not a drop target. @@ -1813,3 +1821,33 @@ mod icon_accent_tests { } } } + +#[cfg(test)] +mod dropdown_toggle_tests { + use crate::scenario::{Harness, rect_of}; + use serde_json::json; + + /// A second click on the button that opened a drop-down closes it (#1522): the workspace + /// switcher's menu, and a panel's command list. + #[test] + fn a_second_click_on_its_button_closes_a_drop_down() { + let mut h = Harness::new(); + h.frames(3); + let ws = rect_of("workspace_switcher").unwrap().center(); + h.step(&json!({"click": [ws.x, ws.y]})).unwrap(); + assert!(h.app.viewport.context_menu.is_some(), "the first click opens the workspace menu"); + h.step(&json!({"click": [ws.x, ws.y]})).unwrap(); + h.frames(2); + assert!(h.app.viewport.context_menu.is_none(), "the second click closes it instead of reopening it"); + h.step(&json!({"click": [ws.x, ws.y]})).unwrap(); + assert!(h.app.viewport.context_menu.is_some(), "and a third opens it again"); + h.step(&json!({"key": "Escape"})).unwrap(); + + h.step(&json!({"click": {"handle": "panel:SOLID:CREATE"}})).unwrap(); + h.frames(2); + assert!(rect_of("toolbar_dropdown").is_some(), "the first click opens the panel's list"); + h.step(&json!({"click": {"handle": "panel:SOLID:CREATE"}})).unwrap(); + h.frames(2); + assert!(rect_of("toolbar_dropdown").is_none(), "the second click closes it"); + } +}