Home: turn off commands that act on the hidden design (#322)

The Home page hides the open design, yet Save, Undo, Redo, File › Insert,
Save As and Export, and the design's keyboard shortcuts (Ctrl+S/Z/Y,
Delete, V, F2, F6, Esc, S and tool keys such as E) still acted on it:
Delete removed the selected body unseen, Save wrote a design the user
could not see, and tool keys left Home without being asked to.

On Home the top bar's Save, Undo and Redo are dimmed with a translated
"open a design first" tooltip, the File menu's design items are greyed
out, and only New, Open and Preferences keep their shortcuts. Everything
works as before once Home is closed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Tobias BreckleandClaude Opus 5.5 authored and GitHub committed 2026-10-12 11:44:23 -04:00
1 parent 4d4b3a715f
commit 06e517eb4e
5 files changed
+158 -27

No files matched your search

+1
View File
@@ -187,6 +187,7 @@
"ui.multiple": "Mehrfach",
"ui.named_views": "Benannte Ansichten",
"ui.navigation": "Navigation",
"ui.needs_design": "{command}: Öffnen Sie zuerst ein Design",
"ui.new_body": "Neuer Körper",
"ui.new_design": "Neues Design",
"ui.normal": "Normal",
+1
View File
@@ -187,6 +187,7 @@
"ui.multiple": "Multiple",
"ui.named_views": "Named Views",
"ui.navigation": "Navigation",
"ui.needs_design": "{command}: open a design first",
"ui.new_body": "New Body",
"ui.new_design": "New Design",
"ui.normal": "Normal",
+1
View File
@@ -187,6 +187,7 @@
"ui.multiple": "複数",
"ui.named_views": "名前付きビュー",
"ui.navigation": "ナビゲーション",
"ui.needs_design": "{command}: 先にデザインを開いてください",
"ui.new_body": "新規ボディ",
"ui.new_design": "新規デザイン",
"ui.normal": "標準",
+1
View File
@@ -187,6 +187,7 @@
"ui.multiple": "다중",
"ui.named_views": "명명된 뷰",
"ui.navigation": "탐색",
"ui.needs_design": "{command}: 먼저 디자인을 열어 주세요",
"ui.new_body": "새 바디",
"ui.new_design": "새 디자인",
"ui.normal": "일반",
+154 -27
View File
@@ -104,45 +104,55 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text);
}
let mut x = left + if compact_actions { 60.0 } else { 128.0 };
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool {
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str, enabled: bool| -> bool {
let width = if compact_actions { 22.0 } else { 26.0 };
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(width, 26.0));
x += if compact_actions { 22.0 } else { 30.0 };
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
let resp = ui.interact(br, ui.id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if resp.hovered() {
if enabled && resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
icons::paint(
ui.painter(),
br.shrink(5.0),
icon,
Color32::from_rgb(225, 230, 238),
Color32::from_rgb(120, 140, 170),
Color32::from_rgb(120, 190, 255),
dim(Color32::from_rgb(225, 230, 238)),
dim(Color32::from_rgb(120, 140, 170)),
dim(Color32::from_rgb(120, 190, 255)),
);
resp.on_hover_text(tip).clicked()
let clicked = resp.on_hover_text(tip).clicked();
enabled && clicked
};
let file = click(ui, "folder", app.tr("ui.file"));
if click(ui, "save", &format!("{} (Ctrl+S)", app.tr("ui.save"))) {
let file = click(ui, "folder", app.tr("ui.file"), true);
// Save, Undo and Redo act on the design, which Home hides.
let shown = design_shown(app);
let tip = |id: &str, key: &str| {
let label = app.tr(id);
if shown { format!("{label} ({key})") } else { crate::i18n::format("ui.needs_design", &[("command", label)]) }
};
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
if click(ui, "save", &save_tip, shown) {
save(app);
}
if click(ui, "undo", &format!("{} (Ctrl+Z)", app.tr("ui.undo"))) {
if click(ui, "undo", &undo_tip, shown) {
let _ = app.run("edit.undo", json!({}));
}
if click(ui, "redo", &format!("{} (Ctrl+Y)", app.tr("ui.redo"))) {
if click(ui, "redo", &redo_tip, shown) {
let _ = app.run("edit.redo", json!({}));
}
if click(ui, "home", app.tr("ui.home")) {
if click(ui, "home", app.tr("ui.home"), true) {
let rev = app.session.revision;
app.home.show(rev);
}
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip, true) {
app.ui.dark = !app.ui.dark;
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
}
if click(ui, "help", app.tr("ui.help")) {
if click(ui, "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x);
ui.ctx().data_mut(|d| {
@@ -239,9 +249,12 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
let item_if = |ui: &mut egui::Ui, label: &str, key: &str, enabled: bool| {
ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
let item = |ui: &mut egui::Ui, label: &str, key: &str| item_if(ui, label, key, true);
// Insert, Save and Export act on the design, which Home hides.
let shown = design_shown(app);
if item(ui, app.tr("ui.new_design"), "Ctrl+N") {
crate::documents::new_design(app);
close = true;
@@ -270,36 +283,36 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
ui.close();
}
});
if item(ui, "Insert STEP…", "") {
if item_if(ui, "Insert STEP…", "", shown) {
app.start("file.insert_step");
close = true;
}
if item(ui, "Insert Mesh…", "") {
if item_if(ui, "Insert Mesh…", "", shown) {
app.start("file.insert_mesh");
close = true;
}
if item(ui, app.tr("ui.save"), "Ctrl+S") {
if item_if(ui, app.tr("ui.save"), "Ctrl+S", shown) {
save(app);
close = true;
}
if item(ui, &format!("{}…", app.tr("ui.save_as")), "") {
if item_if(ui, &format!("{}…", app.tr("ui.save_as")), "", shown) {
save_as(app);
close = true;
}
ui.separator();
if item(ui, "Export STL…", "") {
if item_if(ui, "Export STL…", "", shown) {
export(app, "stl");
close = true;
}
if item(ui, "Export STEP…", "") {
if item_if(ui, "Export STEP…", "", shown) {
export(app, "step");
close = true;
}
if item(ui, "Export 3MF…", "") {
if item_if(ui, "Export 3MF…", "", shown) {
export(app, "3mf");
close = true;
}
if item(ui, "Export OBJ…", "") {
if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj");
close = true;
}
@@ -325,6 +338,12 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
}
/// Whether the open design is on screen. Home hides it, so commands that act on it (save,
/// undo, insert, export, tool shortcuts) are off there.
pub fn design_shown(app: &SolveApp) -> bool {
!app.home.open
}
pub fn save(app: &mut SolveApp) {
if crate::browser::needs_capture(app, crate::browser::SAVE) {
app.tree.capture_prompt = Some(crate::browser::SAVE.into());
@@ -832,13 +851,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
i.events.iter().filter_map(|e| if let egui::Event::Key { key, pressed: true, .. } = e { Some(*key) } else { None }).collect();
(keys, i.modifiers)
});
// Home hides the design: only New, Open and Preferences work there.
let shown = design_shown(app);
for k in keys {
use egui::Key;
if mods.command {
match k {
Key::Z => drop(app.run("edit.undo", json!({}))),
Key::Y => drop(app.run("edit.redo", json!({}))),
Key::S => save(app),
Key::N => crate::documents::new_design(app),
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
@@ -846,6 +864,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.open_path_async(&p);
}
}
_ if !shown => {}
Key::Z => drop(app.run("edit.undo", json!({}))),
Key::Y => drop(app.run("edit.redo", json!({}))),
Key::S => save(app),
_ => {
if let Some(id) = crate::keymap::command_for(app, &crate::keymap::key_name(k, mods)) {
app.start(id);
@@ -854,6 +876,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
}
continue;
}
if !shown {
continue;
}
match k {
Key::Escape => {
app.esc_handled = true;
@@ -983,3 +1008,105 @@ mod header_report_tests {
assert_eq!(h.app.session.doc.to_json(), before.0);
}
}
#[cfg(test)]
mod home_tests {
use super::*;
/// Home hides the design: its Save, Undo and Redo buttons, the File menu's Insert, Save
/// and Export, and the design's keyboard shortcuts leave it alone. They work again once
/// the design is back.
#[test]
fn design_actions_are_off_on_home() {
// A design with history both ways, a selection and unsaved changes, behind Home.
let on_home = || {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Main"})).unwrap();
h.app.run("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
h.app.session.selection = vec![solvecraft_engine::Sel::Body { name: "Main".into() }];
let rev = h.app.session.revision;
h.app.home.show(rev);
h.frames(2);
h
};
let mut h = on_home();
assert!(!design_shown(&h.app));
// Everything an action could change: the design, its history and file, files written,
// the selection and visibility, and whether it left Home or opened a tool, dialog or the command box.
let state = |h: &crate::scenario::Harness| {
let files = std::fs::read_dir(&h.dir).map_or(0, |d| d.count());
let doc = serde_json::to_value(&*h.app.session.doc).unwrap();
let busy = (h.app.tool.is_some(), h.app.dialog.is_some(), h.app.ui.palette_open);
(
doc,
h.app.session.undo.len(),
h.app.session.redo.len(),
h.app.session.path.clone(),
files,
h.app.session.selection.clone(),
h.app.session.visibility.hidden_bodies.clone(),
h.app.home.open,
busy,
)
};
let click = |h: &mut crate::scenario::Harness, handle: &str| {
let at = crate::scenario::handle_at(handle).unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
};
let mut actions: Vec<(String, Box<dyn Fn(&mut crate::scenario::Harness)>)> = Vec::new();
for icon in ["save", "undo", "redo"] {
actions.push((format!("{icon} button"), Box::new(move |h| click(h, &format!("appbar:{icon}")))));
}
for (key, cmd) in [("S", true), ("Z", true), ("Y", true), ("Delete", false), ("V", false), ("E", false), ("S", false)] {
let name = if cmd { format!("Ctrl+{key}") } else { key.to_string() };
actions.push((name, Box::new(move |h| drop(h.call("ui.key", json!({"key": key, "cmd": cmd}))))));
}
for item in ["Insert STEP…", "Insert Mesh…", "Save", "Save As…", "Export STL…", "Export STEP…", "Export 3MF…", "Export OBJ…"] {
actions.push((
format!("File › {item}"),
Box::new(move |h| {
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_file_menu"), false));
click(h, "appbar:folder");
let _ = h.step(&json!({"widget": item}));
}),
));
}
// Each action on its own design, so one cannot hide another.
let mut acted = Vec::new();
for (name, act) in &actions {
let mut h = on_home();
let before = state(&h);
act(&mut h);
h.settle();
if state(&h) != before {
acted.push(name.clone());
}
}
assert!(acted.is_empty(), "these acted on the hidden design from Home: {acted:?}");
h.app.home.open = false;
h.frames(2);
let at = crate::scenario::handle_at("appbar:redo").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
assert_eq!(h.app.session.redo.len(), 0, "Redo works once Home is closed");
h.call("ui.key", json!({"key": "Z", "cmd": true}));
h.settle();
assert_eq!(h.app.session.redo.len(), 1, "Ctrl+Z works once Home is closed");
let at = crate::scenario::handle_at("appbar:save").unwrap();
h.call("ui.click", json!({"x": at.x, "y": at.y}));
h.settle();
assert!(h.app.session.path.is_some() && !h.app.session.is_dirty(), "the design saves once Home is closed");
}
/// The disabled buttons' tooltip is translated in every catalog (not English fallback).
#[test]
fn needs_design_tooltip_is_translated() {
let english = crate::i18n::Locale::for_language("en").text("ui.needs_design");
assert!(english.contains("{command}"));
for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en") {
let text = crate::i18n::Locale::for_language(catalog.id).text("ui.needs_design");
assert!(text != english && text.contains("{command}"), "{} must translate ui.needs_design", catalog.id);
}
}
}