MENU-BAR: pointing at another menu title switches the open menu in the in-window bar

This commit is contained in:
radialmonster 2026-10-08 20:40:23 -04:00
parent bba4172f83
commit caa102aabd
3 changed files with 126 additions and 5 deletions

View File

@ -752,10 +752,29 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
if total <= max_width {
let saved = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = TITLE_GAP;
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
egui::Popup::menu(&r).show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
// All titles first, then their popups, so a title the pointer moves onto can take over
// from the open menu before either is drawn (no frame with two menus or none).
let titles: Vec<egui::Response> = bar
.iter()
.map(|(title, _)| {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
r
})
.collect();
let confirmed = switch_open_title(ui.ctx(), &titles);
for ((_, items), r) in bar.iter().zip(&titles) {
// (opening and closing by click is done in `switch_open_title`)
// (the click confirming a menu a hover opened must not close it again)
let close = if confirmed == Some(egui::Popup::default_response_id(r)) {
egui::PopupCloseBehavior::IgnoreClicks
} else {
egui::PopupCloseBehavior::CloseOnClick
};
egui::Popup::menu(r)
.close_behavior(close)
.open_memory(None)
.show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
}
ui.spacing_mut().item_spacing.x = saved;
} else {
@ -781,6 +800,47 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
ui.cursor().left() - start
}
/// Native menu-bar behaviour for the top-level titles: while one menu is open, resting the pointer
/// on another title closes it and opens that one, and a click on another title opens it in that
/// same click (instead of only closing the open menu as a click outside). Without an open menu
/// hovering does nothing; clicking a title toggles its menu. egui keeps one popup open at a time.
/// Returns the menu whose title was just clicked right after a hover opened it.
fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<egui::Id> {
let popup = egui::Popup::default_response_id;
let key = egui::Id::new("lc-menubar-hover-opened");
let open = titles.iter().position(|r| egui::Popup::is_id_open(ctx, popup(r)));
// the menu a hover opened, until it closes or is clicked
let mut confirmed = None;
let mut hover_opened: Option<egui::Id> = ctx.data(|d| d.get_temp(key)).filter(|id| open.is_some_and(|o| popup(&titles[o]) == *id));
for (i, r) in titles.iter().enumerate() {
if r.clicked() {
// the click that follows a hover switch confirms the menu the hover opened
if hover_opened == Some(popup(r)) {
confirmed = Some(popup(r));
} else {
egui::Popup::toggle_id(ctx, popup(r));
}
hover_opened = None;
} else if r.hovered() && open.is_some_and(|o| o != i) {
egui::Popup::open_id(ctx, popup(r));
hover_opened = Some(popup(r));
// a pointer move alone doesn't schedule the frame that draws the new menu
ctx.request_repaint();
}
}
// (leaving the titles ends it: a later click on the open title closes the menu)
let hover_opened = hover_opened.filter(|_| titles.iter().any(|r| r.hovered()));
ctx.data_mut(|d| match hover_opened {
Some(id) => {
d.insert_temp(key, id);
}
None => {
d.remove::<egui::Id>(key);
}
});
confirmed
}
/// A submenu row showing `text` at `depth`, whose rows `children` draws one level deeper,
/// bounded like every level. The row is registered as `menusub:<label>` (the untranslated
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.

View File

@ -84,3 +84,64 @@ fn a_tall_menu_scrolls_instead_of_growing_past_the_window() {
assert!(!rows.is_empty(), "no submenu rows registered");
assert!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), "every row fits a 420 pt window: {rows:?}");
}
// ---- top-level menus switch on hover, like native menu bars
/// The title whose menu is open (the open level hangs from its left edge), or none.
fn open_menu(h: &Headless) -> Option<String> {
let level = widget(h, "menu-level:1")?;
widgets_with(h, "menu:").into_iter().filter(|(id, _)| id != "menu:all").find(|(_, r)| (r.left() - level.left()).abs() < 12.0).map(|(id, _)| id)
}
fn go(h: &mut Headless, method: &str, id: &str) {
let r = h.request(method, json!({"id": id}), T);
assert_eq!(r["ok"], true, "{method} {id}: {r}");
h.settle(SETTLE);
}
#[test]
fn hovering_another_title_switches_the_open_menu() {
let mut h = app([1280.0, 703.0]);
// hovering with no menu open does nothing
go(&mut h, "ui.hoverWidget", "menu:Edit");
assert_eq!(open_menu(&h), None);
go(&mut h, "ui.clickWidget", "menu:File");
assert_eq!(open_menu(&h).as_deref(), Some("menu:File"));
for title in ["Edit", "View", "Photo", "Window", "Help", "File"] {
go(&mut h, "ui.hoverWidget", &format!("menu:{title}"));
assert_eq!(open_menu(&h), Some(format!("menu:{title}")), "after hovering {title}");
}
}
#[test]
fn clicking_another_title_opens_it_in_one_click() {
let mut h = app([1280.0, 703.0]);
go(&mut h, "ui.clickWidget", "menu:Edit");
assert_eq!(open_menu(&h).as_deref(), Some("menu:Edit"));
// move away from the titles first so only the click can be what switches
let r = h.request("ui.move", json!({"x": 640.0, "y": 400.0}), T);
assert_eq!(r["ok"], true);
h.settle(SETTLE);
go(&mut h, "ui.clickWidget", "menu:View");
assert_eq!(open_menu(&h).as_deref(), Some("menu:View"));
// clicking the open title closes it
go(&mut h, "ui.clickWidget", "menu:View");
assert_eq!(open_menu(&h), None);
}
#[test]
fn click_outside_and_escape_close_the_open_menu() {
let mut h = app([1280.0, 703.0]);
go(&mut h, "ui.clickWidget", "menu:File");
assert!(open_menu(&h).is_some());
let r = h.request("ui.click", json!({"x": 640.0, "y": 600.0}), T);
assert_eq!(r["ok"], true);
h.settle(SETTLE);
assert_eq!(open_menu(&h), None, "click outside");
go(&mut h, "ui.clickWidget", "menu:Photo");
assert!(open_menu(&h).is_some());
let r = h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert_eq!(open_menu(&h), None, "Escape");
}

View File

@ -531,7 +531,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu |
| MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux (with one menu open, the pointer on another title switches to it, as in native menu bars); ⌫ and X stay egui-handled (contextual), so they show no key in the native menu |
| MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | |
| MENU-APP-SETTINGS | Settings… | P0 | ✅ | `cmd:app.settings` | app menu on macOS, Edit menu elsewhere |
| MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | |