UI: share viewport-aware scrolling across menus

Incorporate @aiongg's shared menu-button, anchor-aware sizing, and
reopen-reset work from #279 (717ddb6), retaining #281's clipped-submenu
and scrollbar interaction fixes.

Use the wrapper for workspace, zoom, and panel menus as well as the
menu bar. Preserve checkbox dismissal and cover anchor placement,
scroll reset, and very short viewports with regression tests.
This commit is contained in:
skylartaylor
2026-10-09 20:55:54 -06:00
parent 07b33edbef
commit d5c1755329
6 changed files with 280 additions and 88 deletions
+47 -37
View File
@@ -51,46 +51,56 @@ 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())
{
if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked()
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())
{
let _ = app.run("window.workspace", json!({"name": w}));
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
.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();
}
}
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.");
@@ -511,7 +521,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}));
+223 -44
View File
@@ -1743,45 +1743,59 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
let _ = app.run(id, p);
}
/// 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();
let mut menus = menu_tree();
if crate::i18n::is_rtl(&lang) {
menus.reverse();
}
for (menu, entries) in menus {
egui::containers::menu::MenuButton::new(crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)))
.config(egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside))
.ui(ui, |ui| {
if crate::i18n::is_rtl(&lang) {
ui.set_max_width(320.0);
}
scroll_menu(ui, |ui| {
ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| {
ui.set_min_width(240.0);
let hidden = menu_items(app, ui, &entries, menu);
if hidden > 0 && !app.ui.show_full_menus {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() {
app.ui.show_full_menus = true;
}
}
});
});
});
}
/// A menu button whose popup stays within the viewport and scrolls when needed.
/// All menu buttons share this wrapper, including workspace, zoom and panel menus.
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>> {
use egui::containers::menu::{MenuButton, MenuConfig};
// A menu opens from a button already shown in the previous frame. Base its height on
// that anchor, not the popup's eventual position, so placement cannot oscillate.
let button = ui.ctx().read_response(ui.next_auto_id()).map(|response| response.rect);
let max_height = menu_max_height(ui, button);
let contents = |ui: &mut egui::Ui| {
// Override the popup's initial available height, including when it opens upwards.
ui.set_max_height(max_height);
let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0);
if ui.is_sizing_pass() {
// Closing and reopening a menu starts at its first command again.
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, add_contents).inner
};
// Scrollbar clicks must not dismiss the popup; command rows close it explicitly.
let config = MenuConfig::find(ui).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
let (response, inner) = if egui::containers::menu::is_in_menu(ui) {
clipped_submenu(ui, atoms, config, contents)
} else {
MenuButton::new(atoms).config(config).ui(ui, contents)
};
egui::InnerResponse { response, inner: inner.map(|response| response.inner) }
}
/// Keep every command reachable when the viewport is short or the UI is enlarged.
fn scroll_menu<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum().y;
let max_height = (ui.ctx().content_rect().height() - frame_margin).max(1.0);
ui.set_max_height(max_height);
egui::ScrollArea::vertical().max_height(max_height).show(ui, contents).inner
/// The tallest menu that fits on either side of its anchor, including the popup frame.
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 row, aligned with its top or bottom.
Some(rect) if egui::containers::menu::is_in_menu(ui) => (screen.bottom() - rect.top()).max(rect.bottom() - screen.top()),
Some(rect) => (screen.bottom() - rect.bottom()).max(rect.top() - screen.top()),
None => screen.height(),
};
let chrome = egui::Frame::menu(ui.style()).total_margin().sum().y + 8.0;
(space.min(screen.height()) - chrome).max(1.0)
}
fn clipped_submenu<R>(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
/// egui's submenu hover test uses the full row rect, even outside a scroll area's clip.
fn clipped_submenu<'a, R>(
ui: &mut egui::Ui,
atoms: impl egui::IntoAtoms<'a>,
config: egui::containers::menu::MenuConfig,
contents: impl FnOnce(&mut egui::Ui) -> R,
) -> (egui::Response, Option<egui::InnerResponse<R>>) {
use egui::containers::menu::{MenuState, SubMenu, SubMenuButton};
let id = SubMenu::id_from_widget_id(ui.next_auto_id());
@@ -1790,21 +1804,49 @@ fn clipped_submenu<R>(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl
if open {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let button = SubMenuButton::new(label);
let mut response = ui.add(button.button);
let button = SubMenuButton::new(atoms).config(config);
let response = ui.add(button.button);
ui.style_mut().visuals.widgets.inactive = inactive;
let clip = ui.clip_rect();
if !response.rect.intersect(clip).is_positive() {
if open {
MenuState::from_ui(ui, |state, _| state.open_item = None);
}
return None;
return (response, None);
}
// egui expands an unclipped hover rectangle by half the item spacing.
// Undo the half-spacing expansion used by SubMenu::show after clamping to the clip.
let hover_margin = ui.spacing().item_spacing / 2.0;
response.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin);
response.interact_rect = response.interact_rect.intersect(clip);
button.sub_menu.show(ui, &response, contents).map(|response| response.inner)
let mut clipped = response.clone();
clipped.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin);
clipped.interact_rect = response.interact_rect.intersect(clip);
let inner = button.sub_menu.show(ui, &clipped, contents);
(response, inner)
}
/// 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();
let mut menus = menu_tree();
if crate::i18n::is_rtl(&lang) {
menus.reverse();
}
for (menu, entries) in menus {
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);
}
ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| {
ui.set_min_width(240.0);
let hidden = menu_items(app, ui, &entries, menu);
if hidden > 0 && !app.ui.show_full_menus {
ui.separator();
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() {
app.ui.show_full_menus = true;
}
}
});
});
}
}
/// The key a menu item is hidden by (Edit › Menus).
@@ -1823,8 +1865,9 @@ 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();
hidden +=
clipped_submenu(ui, crate::rtl::widget(ui, shown), |ui| scroll_menu(ui, |ui| menu_items(app, ui, children, &sub))).unwrap_or(0);
menu_button(ui, crate::rtl::widget(ui, shown), |ui| {
hidden += menu_items(app, ui, children, &sub);
});
}
Item::Cmd { label, .. } if !app.ui.show_full_menus && app.ui.hidden_menu_items.contains(&menu_key(path, label)) => hidden += 1,
Item::Cmd { label, id, params, shortcut: _ } => {
@@ -2630,6 +2673,142 @@ mod tests {
assert!(items.len() > 100, "every menu's items are listed: {}", items.len());
}
#[test]
fn menu_button_fits_its_anchor_and_resets_scroll_on_reopen() {
// Workspace/panel menus may be in the middle; the status-bar zoom menu opens upwards.
for anchor_y in [0.0, 130.0, 260.0] {
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0));
let mut tick = 0;
let mut frame = |events: Vec<egui::Event>| {
tick += 1;
let mut button = None;
let mut first = None;
let mut last = None;
let mut output =
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
ui.add_space(anchor_y);
let response = menu_button(ui, "Long menu", |ui| {
for i in 0..60 {
let response = ui.button(format!("Item {i}"));
if i == 0 {
first = Some((response.rect, ui.clip_rect()));
}
if i == 59 {
last = Some((response.rect, ui.clip_rect()));
}
}
});
button = Some(response.response);
});
output.textures_delta.clear();
(button.expect("menu button"), first, last)
};
let click =
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(vec![]);
let (button, _, _) = frame(vec![]);
let anchor = button.rect;
for pressed in [true, false] {
frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]);
}
for _ in 0..3 {
frame(vec![]);
}
let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu");
assert!(screen.contains_rect(popup), "menu at {anchor_y} fits in the viewport: {popup:?}");
assert!(
popup.top() >= anchor.bottom() - 1.0 || popup.bottom() <= anchor.top() + 1.0,
"menu at {anchor_y} stays on one side of its button: {popup:?}, {anchor:?}"
);
let (_, first, last) = frame(vec![]);
let (first, clip) = first.expect("first row");
assert!(clip.contains_rect(first), "newly opened menu starts at the first row");
assert!(!clip.intersects(last.expect("last row").0), "last row starts below the scroll area");
frame(vec![
egui::Event::PointerMoved(popup.center()),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -5000.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
},
]);
for _ in 0..60 {
frame(vec![]);
}
let (_, first, last) = frame(vec![]);
let (last, clip) = last.expect("scrolled last row");
assert!(popup.contains_rect(last) && clip.contains_rect(last), "last row is reachable: {last:?}, {popup:?}, {clip:?}");
assert!(!clip.intersects(first.expect("scrolled first row").0), "menu really scrolled away from its start");
let outside = egui::pos2(screen.right() - 10.0, screen.center().y);
for pressed in [true, false] {
frame(vec![egui::Event::PointerMoved(outside), click(outside, pressed)]);
}
assert!(!egui::Popup::is_any_open(&ctx), "outside click closes the menu");
assert!(frame(vec![]).1.is_none(), "closed menu is no longer drawn");
for pressed in [true, false] {
frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]);
}
for _ in 0..3 {
frame(vec![]);
}
let (_, first, last) = frame(vec![]);
let (first, clip) = first.expect("reopened first row");
assert!(popup.contains_rect(first) && clip.contains_rect(first), "reopening resets to the first row: {first:?}, {clip:?}");
assert!(!clip.intersects(last.expect("reopened last row").0), "reopening discards the old scroll offset");
}
}
#[test]
fn tiny_menu_viewport_does_not_force_the_default_scroll_height() {
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 100.0));
let mut tick = 0;
let mut frame = |events: Vec<egui::Event>| {
tick += 1;
let mut button = None;
let mut output =
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
ui.add_space(35.0);
let response = menu_button(ui, "Long menu", |ui| {
for i in 0..20 {
let _ = ui.button(format!("Item {i}"));
}
});
button = Some(response.response);
});
output.textures_delta.clear();
button.expect("menu button")
};
frame(vec![]);
let button = frame(vec![]);
let pos = button.rect.center();
for pressed in [true, false] {
frame(vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
]);
}
for _ in 0..3 {
frame(vec![]);
}
let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu");
let space = (screen.bottom() - button.rect.bottom()).max(button.rect.top() - screen.top());
assert!(space < 64.0, "fixture has less space than egui's default minimum scroll height");
// A complete command row may not fit, but the scroll area's minimum must not make
// the popup exceed its height budget or cover the button that dismisses it.
assert!(popup.height() <= space, "tiny menu respects its available height: {popup:?}, {space}");
assert!(screen.contains_rect(popup), "tiny menu remains inside the viewport: {popup:?}");
assert!(
popup.top() >= button.rect.bottom() - 1.0 || popup.bottom() <= button.rect.top() + 1.0,
"tiny menu stays beside its anchor: {popup:?}, {:?}",
button.rect
);
}
#[test]
fn clipped_submenu_does_not_open_when_hovering_outside_menu() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+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();
+3 -2
View File
@@ -1330,7 +1330,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")] {
@@ -1385,13 +1385,14 @@ 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;
if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() {
let sets: Vec<u32> = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect();
let _ = app.run("type.openType", json!({"stylisticSets": sets}));
ui.close();
}
}
});
+1 -1
View File
@@ -125,7 +125,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();
+5 -3
View File
@@ -73,12 +73,13 @@ 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() {
let _ = app.run("ink.options", json!({"allToProcess": all}));
ui.close();
}
ui.separator();
let inks = l["inks"].as_array().cloned().unwrap_or_default();
@@ -91,6 +92,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.add_enabled_ui(!all, |ui| {
if ui.checkbox(&mut process, format!("{name} → process")).changed() {
let _ = app.run("ink.options", json!({"ink": name, "toProcess": process}));
ui.close();
}
});
}
@@ -162,7 +164,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();