mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
Keep menus and dialogs usable at larger UI scales (#281)
* ci: simplify testkit dependency match for Clippy
* UI: keep menus and dialogs reachable at larger scales
* 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.
---------
Co-authored-by: skylartaylor <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
7 files changed
+727
-66
No files matched your search
@@ -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.");
|
||||
@@ -534,7 +549,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}));
|
||||
|
||||
+285
-14
@@ -576,7 +576,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
|
||||
/// Preferences: a section list and the section's options. Application options always; units and
|
||||
/// increments when a document is open (InDesign keeps those with the document).
|
||||
fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_height: f32) {
|
||||
let has_doc = d.fields.contains_key("horizontalUnits");
|
||||
let sections: &[(&str, &str)] = if has_doc {
|
||||
&[
|
||||
@@ -615,21 +615,26 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
};
|
||||
let cur = d.s("section");
|
||||
ui.horizontal_top(|ui| {
|
||||
// A fixed height: the separator would otherwise take the whole window.
|
||||
ui.set_min_height(240.0);
|
||||
ui.set_max_height(240.0);
|
||||
// Bound both panes independently so selecting a lower section keeps its options visible.
|
||||
let height = max_height.min(440.0);
|
||||
ui.set_min_height(240.0_f32.min(height));
|
||||
ui.set_max_height(height);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(150.0);
|
||||
for (id, label) in sections {
|
||||
if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||||
d.fields.insert("section".into(), json!(id));
|
||||
egui::ScrollArea::vertical().id_salt("preferences_sections").min_scrolled_height(1.0).max_height(height).show(ui, |ui| {
|
||||
for (id, label) in sections {
|
||||
if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
|
||||
d.fields.insert("section".into(), json!(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(300.0);
|
||||
match cur.as_str() {
|
||||
egui::ScrollArea::vertical().id_salt(("preferences_options", &cur)).min_scrolled_height(1.0).max_height(height).show(ui, |ui| match cur
|
||||
.as_str()
|
||||
{
|
||||
"dictionary" => {
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
@@ -963,7 +968,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page Numbering")).font(semibold(12.0)));
|
||||
check(ui, d, "absolutePageNumbers", crate::i18n::tr(&app.ui.language, "Absolute Numbering (instead of Section Numbering)"));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1025,13 +1030,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
},
|
||||
};
|
||||
egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| {
|
||||
ui.set_min_width(380.0);
|
||||
ui.set_max_width(match d.id.as_str() {
|
||||
let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum();
|
||||
let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0));
|
||||
let preferred_width: f32 = match d.id.as_str() {
|
||||
"alert" => 440.0,
|
||||
"closeDocument" => 380.0,
|
||||
"newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0,
|
||||
_ => 640.0,
|
||||
});
|
||||
};
|
||||
let width = preferred_width.min(available.x);
|
||||
ui.set_min_width(380.0_f32.min(width));
|
||||
ui.set_max_width(width);
|
||||
if crate::i18n::is_rtl(&app.ui.language) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -1042,6 +1051,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0)));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// Keep the title and action buttons outside the scrollable body. Oversized
|
||||
// forms remain reachable at large UI scales, without shrinking their text.
|
||||
let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y);
|
||||
let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0);
|
||||
egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| {
|
||||
match d.id.as_str() {
|
||||
"newDocument" => {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1256,7 +1270,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"insertXref" => insert_xref(app, ui, &mut d),
|
||||
"findFont" => find_font(app, ui, &mut d),
|
||||
"colorPicker" => color_picker(ui, &mut d),
|
||||
"preferences" => preferences(app, ui, &mut d),
|
||||
"preferences" => preferences(app, ui, &mut d, body_height),
|
||||
"print" => print_dialog(app, ui, &mut d),
|
||||
"pdfImport" => {
|
||||
ui.label(egui::RichText::new(if d.b("async") { d.s("name") } else { d.s("path") }).size(11.0));
|
||||
@@ -1560,6 +1574,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " };
|
||||
ui.horizontal(|ui| {
|
||||
@@ -3548,6 +3563,262 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dialog_context(language: &str) -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, language);
|
||||
crate::theme::apply(&ctx, &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default()));
|
||||
ctx
|
||||
}
|
||||
|
||||
fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, events: Vec<egui::Event>) -> Vec<(String, egui::Rect, egui::Rect)> {
|
||||
let time = ctx.input(|i| i.time) + 1.0 / 60.0;
|
||||
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||||
show(app, ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)),
|
||||
egui::Shape::Vec(shapes) => {
|
||||
for s in shapes {
|
||||
collect(s, clip, labels);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for shape in output.shapes {
|
||||
collect(&shape.shape, shape.clip_rect, &mut labels);
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect {
|
||||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted"));
|
||||
assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}");
|
||||
assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}");
|
||||
*rect
|
||||
}
|
||||
|
||||
fn click_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2) {
|
||||
for pressed in [true, false] {
|
||||
dialog_frame(
|
||||
app,
|
||||
ctx,
|
||||
screen,
|
||||
vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2, delta: egui::Vec2) {
|
||||
dialog_frame(
|
||||
app,
|
||||
ctx,
|
||||
screen,
|
||||
vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
// Let smooth wheel scrolling and scrollbar visibility settle before clicking.
|
||||
for _ in 0..30 {
|
||||
dialog_frame(app, ctx, screen, vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_title_and_actions_fit_scaled_viewports() {
|
||||
for language in ["en", "ar"] {
|
||||
for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] {
|
||||
for (command, title) in [("app.preferences", "Preferences"), ("app.newDocumentDialog", "New Document")] {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.language = language.into();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run(command, json!({})).unwrap();
|
||||
let ctx = dialog_context(language);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for expected in [title, "OK", "Cancel"] {
|
||||
visible_label(&labels, screen, crate::i18n::tr(language, expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_sidebar_scroll_keeps_selected_options_visible() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("app.preferences", json!({})).unwrap();
|
||||
let ctx = dialog_context("en");
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
let sidebar = visible_label(&labels, screen, "General").center();
|
||||
assert!(
|
||||
!labels.iter().any(|(text, rect, clip)| text == "File Handling" && clip.contains_rect(*rect)),
|
||||
"last section starts below the sidebar"
|
||||
);
|
||||
scroll_dialog(&mut app, &ctx, screen, sidebar, egui::vec2(0.0, -600.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
let files = visible_label(&labels, screen, "File Handling").center();
|
||||
click_dialog(&mut app, &ctx, screen, files);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("section"), "files");
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for expected in ["Document Recovery Data", "Save recovery data every:", "OK", "Cancel"] {
|
||||
visible_label(&labels, screen, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_horizontal_scroll_reaches_clipped_options() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("app.preferences", json!({})).unwrap();
|
||||
let ctx = dialog_context("en");
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
let body = visible_label(&labels, screen, "When Scaling").center();
|
||||
let option = "Absolute Numbering (instead of Section Numbering)";
|
||||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap();
|
||||
assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}");
|
||||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(-600.0, 0.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for expected in [option, "Preferences", "OK", "Cancel"] {
|
||||
visible_label(&labels, screen, expected);
|
||||
}
|
||||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, option).center());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_document_vertical_scroll_reaches_last_field() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
let ctx = dialog_context("en");
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(392.0, 271.0));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
let body = visible_label(&labels, screen, "Width").center();
|
||||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Primary Text Frame").unwrap();
|
||||
assert!(rect.bottom() > clip.bottom(), "last field starts vertically clipped");
|
||||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for expected in ["Primary Text Frame", "New Document", "OK", "Cancel"] {
|
||||
visible_label(&labels, screen, expected);
|
||||
}
|
||||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Primary Text Frame").center());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_new_document_leftmost_fields_remain_reachable() {
|
||||
for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.language = "ar".into();
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
let fields = [("height", "66p1"), ("gutter", "1p2"), ("marginBottom", "3p3"), ("marginOutside", "3p4")];
|
||||
for (field, value) in fields {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(value));
|
||||
}
|
||||
let ctx = dialog_context("ar");
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for (_, value) in fields {
|
||||
visible_label(&labels, screen, value);
|
||||
}
|
||||
let body = visible_label(&labels, screen, "66p1").center();
|
||||
for x in [-600.0, 600.0] {
|
||||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(x, 0.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for (_, value) in fields {
|
||||
visible_label(&labels, screen, value);
|
||||
}
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "66p1").center());
|
||||
dialog_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
screen,
|
||||
vec![
|
||||
egui::Event::Key {
|
||||
key: egui::Key::A,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers { ctrl: true, command: true, ..Default::default() },
|
||||
},
|
||||
egui::Event::Text("88p8".into()),
|
||||
],
|
||||
);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "leftmost numeric field accepts edits");
|
||||
scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
let label = crate::i18n::tr("ar", "Primary Text Frame");
|
||||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, label).center());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "leftmost checkbox remains clickable after scrolling");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_dialogs_cancel_and_reopen_without_applying_edits() {
|
||||
for (command, title, field) in [("app.preferences", "Preferences", "recoveryMinutes"), ("app.newDocumentDialog", "New Document", "pages")] {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run(command, json!({})).unwrap();
|
||||
let original = app.ui.dialog.as_ref().unwrap().fields[field].clone();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(42));
|
||||
let ctx = dialog_context("en");
|
||||
for size in [egui::vec2(1178.0, 814.0), egui::vec2(392.0, 271.0), egui::vec2(589.0, 407.0)] {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
for expected in [title, "OK", "Cancel"] {
|
||||
visible_label(&labels, screen, expected);
|
||||
}
|
||||
}
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0));
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Cancel").center());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.session.active().is_none(), "cancelling must not create a document");
|
||||
app.run(command, json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], original, "cancelled edits must not survive reopening");
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
}
|
||||
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
|
||||
visible_label(&labels, screen, title);
|
||||
dialog_frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
screen,
|
||||
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }],
|
||||
);
|
||||
assert!(app.ui.dialog.is_none(), "Escape must also close the reopened dialog");
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
|
||||
|
||||
+374
-2
@@ -1837,6 +1837,86 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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());
|
||||
let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(id));
|
||||
let inactive = ui.style().visuals.widgets.inactive;
|
||||
if open {
|
||||
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
|
||||
}
|
||||
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 (response, None);
|
||||
}
|
||||
// Undo the half-spacing expansion used by SubMenu::show after clamping to the clip.
|
||||
let hover_margin = ui.spacing().item_spacing / 2.0;
|
||||
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)
|
||||
}
|
||||
|
||||
/// A native menu bar item was chosen (macOS): like [`activate`], except that the menu bar takes
|
||||
/// ⌘A, ⌘C, ⌘X, ⌘V, ⌘Z and ⇧⌘Z before the window sees them, so while a text field has keyboard
|
||||
/// focus, Select All, Copy, Cut, Paste, Undo and Redo act on that field, as in any Mac app.
|
||||
@@ -1883,7 +1963,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);
|
||||
}
|
||||
@@ -1917,7 +1997,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);
|
||||
});
|
||||
}
|
||||
@@ -2166,6 +2246,97 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exercise the real dropdown, including clipping and wheel input, on a short viewport.
|
||||
#[test]
|
||||
fn oversized_edit_menu_scrolls_to_preferences() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(700.0, 260.0));
|
||||
let mut tick = 0;
|
||||
let mut frame = |app: &mut DesignApp, events: Vec<egui::Event>| {
|
||||
tick += 1;
|
||||
let mut output =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| menu_bar(app, ui));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut labels = Vec::new();
|
||||
fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)),
|
||||
egui::Shape::Vec(shapes) => {
|
||||
for shape in shapes {
|
||||
collect(shape, clip, labels);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for shape in output.shapes {
|
||||
collect(&shape.shape, shape.clip_rect, &mut labels);
|
||||
}
|
||||
labels
|
||||
};
|
||||
frame(&mut app, vec![]);
|
||||
let labels = frame(&mut app, vec![]);
|
||||
let edit = labels.iter().find(|(text, _, _)| text == "Edit").unwrap().1.center();
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(edit),
|
||||
egui::Event::PointerButton { pos: edit, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let layer = ctx.layer_id_at(egui::pos2(edit.x + 80.0, 100.0)).expect("menu layer");
|
||||
let bounds = ctx.memory(|m| m.area_rect(layer.id)).expect("menu bounds");
|
||||
let track = egui::pos2(bounds.right() - 9.0, bounds.bottom() - 25.0);
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(track),
|
||||
egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
assert!(egui::Popup::is_any_open(&ctx), "clicking the scroll track must not dismiss the menu");
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(egui::pos2(edit.x + 80.0, 100.0)),
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -3000.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
},
|
||||
],
|
||||
);
|
||||
let mut labels = Vec::new();
|
||||
for _ in 0..60 {
|
||||
labels = frame(&mut app, vec![]);
|
||||
}
|
||||
let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Preferences…").expect("Preferences is rendered");
|
||||
assert!(screen.contains_rect(*rect) && clip.contains_rect(*rect), "Preferences must be visible after scrolling: {rect:?}, {clip:?}");
|
||||
let pos = rect.center();
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
&mut app,
|
||||
vec![
|
||||
egui::Event::PointerMoved(pos),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"));
|
||||
assert!(!egui::Popup::is_any_open(&ctx), "choosing a command dismisses the menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_menu_items_open_their_dialogs() {
|
||||
for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] {
|
||||
@@ -2911,4 +3082,205 @@ 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_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());
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0));
|
||||
let entries = vec![Item::Sub("Hidden submenu".into(), vec![cmd_item("app.preferences", Value::Null)])];
|
||||
let mut tick = 0;
|
||||
let mut frame = |spacing: f32, events: Vec<egui::Event>| {
|
||||
tick += 1;
|
||||
let mut button_rect = egui::Rect::NOTHING;
|
||||
let mut hidden_submenu = None;
|
||||
let mut output =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
let response = ui.menu_button("Menu", |ui| {
|
||||
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
|
||||
ui.add_space(spacing);
|
||||
let button_id = ui.next_auto_id();
|
||||
menu_items(&mut app, ui, &entries, "Test");
|
||||
let response = ui.ctx().read_response(button_id).expect("submenu button response");
|
||||
let open = egui::containers::menu::MenuState::from_ui(ui, |state, _| state.open_item.is_some());
|
||||
hidden_submenu = Some((response.rect, ui.clip_rect(), open));
|
||||
});
|
||||
});
|
||||
button_rect = response.response.rect;
|
||||
});
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
(button_rect, hidden_submenu)
|
||||
};
|
||||
frame(500.0, vec![]);
|
||||
let button = frame(500.0, vec![]).0.center();
|
||||
for pressed in [true, false] {
|
||||
frame(
|
||||
500.0,
|
||||
vec![
|
||||
egui::Event::PointerMoved(button),
|
||||
egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
|
||||
],
|
||||
);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
frame(500.0, vec![]);
|
||||
}
|
||||
let (_, Some((hidden, clip, open))) = frame(500.0, vec![]) else { panic!("test menu is open") };
|
||||
assert!(!open, "submenu starts closed");
|
||||
assert!(screen.contains_rect(hidden), "hidden row remains inside the screen: {hidden:?}");
|
||||
assert!(!clip.intersects(hidden), "submenu must be fully clipped: {hidden:?}, {clip:?}");
|
||||
let (_, Some((_, _, open))) = frame(500.0, vec![egui::Event::PointerMoved(hidden.center())]) else { panic!("test menu remains open") };
|
||||
assert!(!open, "hovering the clipped row's position outside the menu must not open its submenu");
|
||||
|
||||
let partial_spacing = 500.0 - (hidden.top() - clip.bottom()) - 6.0;
|
||||
let (_, Some((partial, clip, open))) = frame(partial_spacing, vec![]) else { panic!("test menu remains open") };
|
||||
assert!(!open);
|
||||
assert!(partial.top() < clip.bottom() && partial.bottom() > clip.bottom(), "row must be partially clipped: {partial:?}, {clip:?}");
|
||||
let outside = egui::pos2(partial.center().x, clip.bottom() + 1.0);
|
||||
let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(outside)]) else { panic!("test menu remains open") };
|
||||
assert!(!open, "hovering the clipped portion of a partially visible row must not open its submenu");
|
||||
let visible = partial.intersect(clip).center();
|
||||
let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(visible)]) else { panic!("test menu remains open") };
|
||||
assert!(open, "hovering the visible portion still opens the submenu");
|
||||
let (_, Some((_, _, open))) = frame(500.0, vec![]) else { panic!("test menu remains open") };
|
||||
assert!(!open, "an open submenu closes when its row becomes fully clipped");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1333,7 +1333,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")] {
|
||||
@@ -1388,13 +1388,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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1410,7 +1411,7 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.menu_button("☰", |ui| {
|
||||
crate::menus::menu_button(ui, "☰", |ui| {
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Rules…"))).clicked() {
|
||||
let _ = app.run("app.paragraphRulesDialog", json!({}));
|
||||
ui.close();
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
Reference in new issue
Block a user