Merge #279: Menus: menus taller than the window scroll

Conflict: crates/ui-egui/src/chrome.rs: next's horizontally scrolling app bar kept; its workspace menu and Delete Workspace submenu call #279's menus::menu_button
Conflict: crates/ui-egui/src/menus.rs: kept next's activate_native/native_menu_enabled/forward_to_text_field and #279's menu_button/menu_max_height
This commit is contained in:
aiongg
2026-10-10 01:56:53 +00:00
6 changed files with 151 additions and 51 deletions
+57 -42
View File
@@ -58,52 +58,67 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.add_space(8.0);
let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current);
ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in
["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)),
|ui| {
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
for w in [
"Essentials",
"Advanced",
"Book",
"Digital Publishing",
"Interactive for PDF",
"Printing and Proofing",
"Typography",
]
.into_iter()
.map(str::to_string)
.chain(customs.iter().cloned())
{
if ui
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
.clicked()
{
let _ = app.run("window.workspace", json!({"name": w}));
ui.close();
}
}
ui.separator();
if ui
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
.button(crate::rtl::widget(
ui,
format!(
"{} {}",
crate::i18n::tr(&app.ui.language, "Reset"),
crate::i18n::workspace_name(&app.ui.language, &current)
),
))
.clicked()
{
let _ = app.run("window.workspace", json!({"name": w}));
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
}
ui.separator();
if ui
.button(crate::rtl::widget(
ui,
format!(
"{} {}",
crate::i18n::tr(&app.ui.language, "Reset"),
crate::i18n::workspace_name(&app.ui.language, &current)
),
))
.clicked()
{
let _ = app.run("window.resetWorkspace", json!({}));
ui.close();
}
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
}
}
});
}
});
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
let _ = app.run("window.newWorkspace", json!({}));
ui.close();
}
if !customs.is_empty() {
crate::menus::menu_button(
ui,
crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")),
|ui| {
for w in &customs {
if ui.button(w).clicked() {
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
ui.close();
}
}
},
);
}
},
);
ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
app.status("Export a PDF, IDML or package to share — no cloud account needed.");
@@ -336,7 +351,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
return;
}
let z = app.view().map(|v| v.zoom).unwrap_or(1.0);
ui.menu_button(egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| {
crate::menus::menu_button(ui, egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| {
for p in [0.05, 0.125, 0.25, 0.5, 0.75, 1.0, 2.0, 4.0, 8.0, 16.0] {
if ui.button(format!("{}%", p * 100.0)).clicked() {
let _ = app.run("view.zoom", json!({"zoom": p}));
+87 -2
View File
@@ -1882,6 +1882,42 @@ fn forward_to_text_field(app: &mut DesignApp, ctx: &egui::Context, id: &str) ->
true
}
/// [`egui::Ui::menu_button`] for every DesignCraft menu: a menu taller than the window scrolls
/// with the mouse wheel inside the larger of the spaces below and above its button, as native
/// menus do. Shorter menus are unchanged.
pub fn menu_button<'a, R>(
ui: &mut egui::Ui,
atoms: impl egui::IntoAtoms<'a>,
add_contents: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<Option<R>> {
// The button's rect from the last frame: a menu only opens on a button that was shown.
let button = ui.ctx().read_response(ui.next_auto_id()).map(|r| r.rect);
let max_height = menu_max_height(ui, button);
ui.menu_button(atoms, |ui| {
let mut scroll = egui::ScrollArea::vertical().max_height(max_height).auto_shrink([true, true]);
if ui.is_sizing_pass() {
// A menu that just opened starts at its top.
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, add_contents).inner
})
}
/// The tallest a menu's contents may be. It depends only on the button and the window (never on
/// where the popup ended up), so the popup's placement cannot oscillate between frames.
fn menu_max_height(ui: &egui::Ui, button: Option<egui::Rect>) -> f32 {
let screen = ui.ctx().content_rect();
let space = match button {
// Submenus open beside their item, going down from its top or up from its bottom.
Some(r) if egui::containers::menu::is_in_menu(ui) => (screen.bottom() - r.top()).max(r.bottom() - screen.top()),
Some(r) => (screen.bottom() - r.bottom()).max(r.top() - screen.top()),
None => screen.height(),
};
// The popup's frame and a small gap to the window edge.
let chrome = egui::Frame::menu(ui.style()).total_margin().sum().y + 8.0;
(space - chrome).max(ui.spacing().interact_size.y)
}
/// The menu bar contents (inside the app bar; macOS uses the native menu instead).
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
let lang = app.ui.language.clone();
@@ -1890,7 +1926,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
menus.reverse();
}
for (menu, entries) in menus {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| {
menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| {
if crate::i18n::is_rtl(&lang) {
ui.set_max_width(320.0);
}
@@ -1924,7 +1960,7 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str
Item::Sub(name, children) => {
let sub = format!("{path}/{name}");
let shown = crate::i18n::tr(&app.ui.language, name).to_owned();
ui.menu_button(crate::rtl::widget(ui, shown), |ui| {
menu_button(ui, crate::rtl::widget(ui, shown), |ui| {
hidden += menu_items(app, ui, children, &sub);
});
}
@@ -2993,4 +3029,53 @@ mod tests {
assert!(app.ui.hidden_menu_items.is_empty());
assert!(items.len() > 100, "every menu's items are listed: {}", items.len());
}
#[test]
fn menu_taller_than_the_window_scrolls() {
// Issue #267: at a large UI scale the menus ran off the bottom of the window.
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0));
let last = std::cell::Cell::new(None::<egui::Rect>);
let button = std::cell::Cell::new(None::<egui::Response>);
let frame = |t: f64, events: Vec<egui::Event>| {
let input = egui::RawInput { screen_rect: Some(screen), time: Some(t), events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
let r = menu_button(ui, "Long", |ui| {
for i in 0..60 {
let r = ui.button(format!("Item {i}"));
if i == 59 {
last.set(Some(r.rect));
}
}
});
button.set(Some(r.response));
});
out.textures_delta.clear();
};
frame(0.0, vec![]);
let b = button.take().unwrap();
let p = b.rect.center();
let click = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(0.1, vec![egui::Event::PointerMoved(p), click(true)]);
frame(0.2, vec![click(false)]);
frame(0.3, vec![]);
frame(0.4, vec![]);
let popup = ctx.memory(|m| m.area_rect(egui::Popup::default_response_id(&b))).unwrap();
assert!(popup.top() >= b.rect.bottom() - 1.0, "the menu opens below its button: {popup:?}");
assert!(popup.bottom() <= screen.bottom(), "the menu fits in the window: {popup:?}");
assert!(last.get().unwrap().top() > screen.bottom(), "the last item starts out of view");
let inside = egui::pos2(popup.center().x, popup.bottom() - 20.0);
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -5000.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
};
frame(0.5, vec![egui::Event::PointerMoved(inside), wheel]);
for i in 0..30 {
frame(0.6 + f64::from(i) * 0.1, vec![]);
}
let l = last.get().unwrap();
assert!(popup.contains_rect(l), "the wheel scrolls the last item into view: {l:?} in {popup:?}");
}
}
+1 -1
View File
@@ -256,7 +256,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu
ui.close();
}
}
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() {
let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null}));
ui.close();
+2 -2
View File
@@ -1365,7 +1365,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
let features: Vec<String> =
c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
ui.menu_button("OpenType", |ui| open_type_menu(app, ui, &features));
crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features));
});
// Underline / Strikethrough Options.
for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] {
@@ -1420,7 +1420,7 @@ pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String
}
}
ui.separator();
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| {
let mask = otf::stylistic_sets(features);
for n in 1..=20u32 {
let mut on = mask & (1 << (n - 1)) != 0;
+1 -1
View File
@@ -121,7 +121,7 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren
ui.close();
}
if !n.starts_with('[') {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| {
if n.contains('/') && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[No Group]"))).clicked() {
let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""}));
ui.close();
+3 -3
View File
@@ -73,8 +73,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() {
let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}}));
}
ui.menu_button("☰", |ui| {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| {
crate::menus::menu_button(ui, "☰", |ui| {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| {
let Ok(l) = app.session.execute("ink.list", &json!({})) else { return };
let mut all = l["allToProcess"].as_bool().unwrap_or(false);
if ui.checkbox(&mut all, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "All Spots to Process"))).changed() {
@@ -160,7 +160,7 @@ fn swatch_row(
ui.close();
}
if !sw.locked {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| {
crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(Top Level)"))).clicked() {
let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null}));
ui.close();