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 committed 2026-10-09 20:55:54 -06:00
1 parent 07b33edbef
commit d5c1755329
6 files changed
+280 -88

No files matched your search

+47 -37
View File
@@ -51,46 +51,56 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.add_space(8.0); ui.add_space(8.0);
let current = app.ui.workspace.clone(); let current = app.ui.workspace.clone();
let shown = crate::i18n::workspace_name(&app.ui.language, &current); 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| { crate::menus::menu_button(
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); ui,
for w in crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)),
["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] |ui| {
.into_iter() let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
.map(str::to_string) for w in
.chain(customs.iter().cloned()) ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"]
{ .into_iter()
if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked() .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.close();
} }
} if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
ui.separator(); let _ = app.run("window.newWorkspace", json!({}));
if ui ui.close();
.button(crate::rtl::widget( }
ui, if !customs.is_empty() {
format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, &current)), crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| {
)) for w in &customs {
.clicked() if ui.button(w).clicked() {
{ let _ = app.run("window.deleteWorkspace", json!({"name": w}));
let _ = app.run("window.resetWorkspace", json!({})); ui.close();
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();
} }
} });
}); }
} },
}); );
ui.add_space(6.0); ui.add_space(6.0);
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { 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."); 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; return;
} }
let z = app.view().map(|v| v.zoom).unwrap_or(1.0); 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] { 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() { if ui.button(format!("{}%", p * 100.0)).clicked() {
let _ = app.run("view.zoom", json!({"zoom": p})); 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); let _ = app.run(id, p);
} }
/// The menu bar contents (inside the app bar; macOS uses the native menu instead). /// A menu button whose popup stays within the viewport and scrolls when needed.
pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { /// All menu buttons share this wrapper, including workspace, zoom and panel menus.
let lang = app.ui.language.clone(); pub fn menu_button<'a, R>(
let mut menus = menu_tree(); ui: &mut egui::Ui,
if crate::i18n::is_rtl(&lang) { atoms: impl egui::IntoAtoms<'a>,
menus.reverse(); add_contents: impl FnOnce(&mut egui::Ui) -> R,
} ) -> egui::InnerResponse<Option<R>> {
for (menu, entries) in menus { use egui::containers::menu::{MenuButton, MenuConfig};
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)) // A menu opens from a button already shown in the previous frame. Base its height on
.ui(ui, |ui| { // that anchor, not the popup's eventual position, so placement cannot oscillate.
if crate::i18n::is_rtl(&lang) { let button = ui.ctx().read_response(ui.next_auto_id()).map(|response| response.rect);
ui.set_max_width(320.0); let max_height = menu_max_height(ui, button);
} let contents = |ui: &mut egui::Ui| {
scroll_menu(ui, |ui| { // Override the popup's initial available height, including when it opens upwards.
ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { ui.set_max_height(max_height);
ui.set_min_width(240.0); let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0);
let hidden = menu_items(app, ui, &entries, menu); if ui.is_sizing_pass() {
if hidden > 0 && !app.ui.show_full_menus { // Closing and reopening a menu starts at its first command again.
ui.separator(); scroll = scroll.vertical_scroll_offset(0.0);
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() { }
app.ui.show_full_menus = true; 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. /// The tallest menu that fits on either side of its anchor, including the popup frame.
fn scroll_menu<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R { fn menu_max_height(ui: &egui::Ui, button: Option<egui::Rect>) -> f32 {
let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum().y; let screen = ui.ctx().content_rect();
let max_height = (ui.ctx().content_rect().height() - frame_margin).max(1.0); let space = match button {
ui.set_max_height(max_height); // Submenus open beside their row, aligned with its top or bottom.
egui::ScrollArea::vertical().max_height(max_height).show(ui, contents).inner 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}; use egui::containers::menu::{MenuState, SubMenu, SubMenuButton};
let id = SubMenu::id_from_widget_id(ui.next_auto_id()); 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 { if open {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
} }
let button = SubMenuButton::new(label); let button = SubMenuButton::new(atoms).config(config);
let mut response = ui.add(button.button); let response = ui.add(button.button);
ui.style_mut().visuals.widgets.inactive = inactive; ui.style_mut().visuals.widgets.inactive = inactive;
let clip = ui.clip_rect(); let clip = ui.clip_rect();
if !response.rect.intersect(clip).is_positive() { if !response.rect.intersect(clip).is_positive() {
if open { if open {
MenuState::from_ui(ui, |state, _| state.open_item = None); 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; let hover_margin = ui.spacing().item_spacing / 2.0;
response.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin); let mut clipped = response.clone();
response.interact_rect = response.interact_rect.intersect(clip); clipped.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin);
button.sub_menu.show(ui, &response, contents).map(|response| response.inner) 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). /// 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) => { Item::Sub(name, children) => {
let sub = format!("{path}/{name}"); let sub = format!("{path}/{name}");
let shown = crate::i18n::tr(&app.ui.language, name).to_owned(); let shown = crate::i18n::tr(&app.ui.language, name).to_owned();
hidden += menu_button(ui, crate::rtl::widget(ui, shown), |ui| {
clipped_submenu(ui, crate::rtl::widget(ui, shown), |ui| scroll_menu(ui, |ui| menu_items(app, ui, children, &sub))).unwrap_or(0); 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, .. } if !app.ui.show_full_menus && app.ui.hidden_menu_items.contains(&menu_key(path, label)) => hidden += 1,
Item::Cmd { label, id, params, shortcut: _ } => { Item::Cmd { label, id, params, shortcut: _ } => {
@@ -2630,6 +2673,142 @@ mod tests {
assert!(items.len() > 100, "every menu's items are listed: {}", items.len()); 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] #[test]
fn clipped_submenu_does_not_open_when_hovering_outside_menu() { fn clipped_submenu_does_not_open_when_hovering_outside_menu() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); 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.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() { 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})); let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null}));
ui.close(); 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> = 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(); 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. // Underline / Strikethrough Options.
for (key, title) in [("underline", "Underline Options"), ("strikethrough", "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.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); let mask = otf::stylistic_sets(features);
for n in 1..=20u32 { for n in 1..=20u32 {
let mut on = mask & (1 << (n - 1)) != 0; 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() { 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 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})); 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(); ui.close();
} }
if !n.starts_with('[') { 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() { 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": ""})); let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""}));
ui.close(); 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() { 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}})); let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}}));
} }
ui.menu_button("☰", |ui| { crate::menus::menu_button(ui, "☰", |ui| {
ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |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 Ok(l) = app.session.execute("ink.list", &json!({})) else { return };
let mut all = l["allToProcess"].as_bool().unwrap_or(false); 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() { 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})); let _ = app.run("ink.options", json!({"allToProcess": all}));
ui.close();
} }
ui.separator(); ui.separator();
let inks = l["inks"].as_array().cloned().unwrap_or_default(); 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| { ui.add_enabled_ui(!all, |ui| {
if ui.checkbox(&mut process, format!("{name} → process")).changed() { if ui.checkbox(&mut process, format!("{name} → process")).changed() {
let _ = app.run("ink.options", json!({"ink": name, "toProcess": process})); let _ = app.run("ink.options", json!({"ink": name, "toProcess": process}));
ui.close();
} }
}); });
} }
@@ -162,7 +164,7 @@ fn swatch_row(
ui.close(); ui.close();
} }
if !sw.locked { 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() { 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})); let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null}));
ui.close(); ui.close();