mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
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:
@@ -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, ¤t);
|
||||
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, ¤t)
|
||||
),
|
||||
))
|
||||
.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, ¤t)
|
||||
),
|
||||
))
|
||||
.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}));
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user