diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index f6943f4..8c5a32e 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -585,7 +585,8 @@ pub static CATALOGUE: &[Capability] = &[ // ---- Tools ---- // Renaming starts from a definition's item menu (Rename…); the search finds it here too. ready_more("model.info", "Model Info", Tools, "Model", "Node and element counts and the model summary", &["model.rename"]), - ready("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model"), + // New starts from the File menu; the search finds it here too. + ready_more("file.open", "Open Model", Tools, "File", "Open a .fieldcraft model", &["file.new"]), ready("file.sample", "Samples", Tools, "File", "Open a sample model, ready to solve"), ready("file.save", "Save", Tools, "File", "Save the model to its .fieldcraft file"), ready("file.save_as", "Save As", Tools, "File", "Save the model to a new .fieldcraft file"), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a0e5a1f..db5da46 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -19,6 +19,16 @@ use fieldcraft_io::roots::{Access, Roots}; use super::{file_param, req}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision}; +pub const NEW: CommandSpec = CommandSpec { + id: "file.new", + label: "New Model", + params: r#"{} — starts an empty model in place of the current one: no design, mesh, definitions, results or journal; unsaved changes are discarded (the window asks first) and the undo history starts empty"#, + form: Some(&[]), + kind: CommandKind::Session, + requires: &[], + run: new_model, +}; + pub const OPEN: CommandSpec = CommandSpec { id: "file.open", label: "Open Model", @@ -89,6 +99,15 @@ pub const SAMPLE: CommandSpec = CommandSpec { run: open_sample, }; +fn new_model(s: &mut Session, _: &Value) -> Result { + let mut empty = Session::default(); + // Nothing to save until a command changes it. + empty.saved_revision = Some(empty.revision); + empty.saved_view = empty.view.clone(); + s.replace_with(empty); + Ok(summary(s, &[])) +} + fn open_sample(s: &mut Session, p: &Value) -> Result { let name: &str = req(p, "name")?; let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::>().join(", "); @@ -375,6 +394,19 @@ mod tests { s.execute("model.material.create", &json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000.0, "poisson": 0.3}})).unwrap(); } + /// `file.new` empties the model, its journal and its undo history, and has nothing to save. + #[test] + fn new_starts_an_empty_model() { + let mut s = Session::default(); + s.execute("file.sample", &json!({"name": "cantilever"})).unwrap(); + s.execute("mesh.node.add", &json!({"label": 10_000, "xyz": [0, 0, 0]})).unwrap(); + let r = s.execute("file.new", &json!({})).unwrap(); + assert_eq!((r["nodes"].as_u64(), r["elements"].as_u64(), r["journal"].as_u64()), (Some(0), Some(0), Some(0)), "{r}"); + assert!(s.model.materials().is_empty() && s.model.steps().is_empty() && s.design.is_none() && s.sample.is_none()); + assert!(!s.is_dirty(), "an empty model has nothing to save"); + assert!(s.execute("edit.undo", &json!({})).is_err(), "the undo history starts empty"); + } + #[test] fn save_and_open_keep_the_model_journal_and_design() { let d = dir("roundtrip"); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 224ef0e..7484467 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -135,6 +135,7 @@ pub static ALL: &[CommandSpec] = &[ define::STEP_ACTIVATE, define::RENAME, define::ACTIVITY, + file::NEW, file::OPEN, file::SAVE, file::SAVE_AS, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c27576e..5909ed8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -248,6 +248,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> crate::files::Replace::File(p) => p.clone(), crate::files::Replace::Sample(n) => format!("sample {n}"), crate::files::Replace::Update => "update".to_string(), + crate::files::Replace::New => "new".to_string(), }}), crate::files::Prompt::Recover => json!("recover"), crate::files::Prompt::Samples => json!("samples"), diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index e74b4c5..6be4cd9 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -359,6 +359,8 @@ pub enum Replace { Sample(&'static str), /// Restarting to finish installing an update ([`crate::updates`]). Update, + /// An empty model (`file.new`). + New, } impl Replace { @@ -367,6 +369,7 @@ impl Replace { Replace::File(p) => file_name(p), Replace::Sample(n) => fieldcraft_engine::sample::find(n).map_or_else(|| (*n).to_string(), |s| format!("the {} sample", s.label)), Replace::Update => "the update".into(), + Replace::New => "a new model".into(), } } } @@ -612,7 +615,9 @@ impl ModelApp { /// would discard unsaved changes. pub fn open_sample(&mut self, name: &str) { let Some(sample) = fieldcraft_engine::sample::find(name) else { - self.set_status(format!("no sample `{name}`"), true); + // An unknown name (a typo, an old `?sample=` link): the samples to choose from (#200). + self.set_status(format!("There is no sample `{name}`: choose one of these"), true); + self.prompt = Some(Prompt::Samples); return; }; if self.has_unsaved_work() { @@ -622,6 +627,23 @@ impl ModelApp { self.load_sample(sample.name); } + /// File › New (`file.new`): start an empty model, asking first when that would discard + /// unsaved changes. + pub fn new_model(&mut self) { + if self.has_unsaved_work() { + self.prompt = Some(Prompt::Unsaved(Replace::New)); + return; + } + self.load_new(); + } + + fn load_new(&mut self) { + if self.run("file.new", json!({})).is_ok() { + self.model_changed(); + self.set_status("New model", false); + } + } + fn load_sample(&mut self, name: &str) { let Some(sample) = fieldcraft_engine::sample::find(name) else { return }; if self.run("file.sample", json!({"name": sample.name})).is_ok() { @@ -897,6 +919,7 @@ impl ModelApp { } } Replace::Sample(name) => self.load_sample(name), + Replace::New => self.load_new(), Replace::Update => self.close_to_update(), } } @@ -916,6 +939,7 @@ impl ModelApp { } "file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover), "file.sample" => self.prompt = Some(Prompt::Samples), + "file.new" => self.new_model(), "diagnostics.bug_report" | "diagnostics.crash_reports" | "diagnostics.crash_report" => self.report_bug(None), "solve.run" => self.solve_all(), "deck.write" if can_save => match preset.get("format").and_then(Value::as_str) { @@ -1208,6 +1232,9 @@ pub fn file_menu(app: &mut ModelApp, ui: &mut egui::Ui) { let key = |k: &str| crate::appbar::shortcut(k); let row = |ui: &mut egui::Ui, label: &str, right: Option<&str>| crate::frame::menu_row(ui, &t, label, right, true).clicked(); let mut act: Option> = None; + if row(ui, "New", Some(&key("N"))) { + act = Some(Box::new(|app| app.new_model())); + } if row(ui, "Open…", Some(&key("O"))) { act = Some(Box::new(|app| app.open_dialog(None))); } @@ -1528,6 +1555,17 @@ mod tests { assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::Sample("impact")))); app.answer_unsaved(Answer::Discard); assert!(app.session.model.step("Impact").is_some()); + // An unknown sample name opens the samples to choose from, and says why. + app.open_sample("crank-armm"); + assert_eq!(app.prompt, Some(Prompt::Samples)); + assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.contains("crank-armm")), "{:?}", app.status); + app.prompt = None; + // File › New over unsaved changes asks first too, then empties the model. + app.run("mesh.node.add", serde_json::json!({"label": 900000, "xyz": [0, 0, 0]})).unwrap(); + app.start("file.new").unwrap(); + assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::New))); + app.answer_unsaved(Answer::Discard); + assert!(app.session.model.nodes().is_empty() && !app.session.is_dirty()); // A model without steps says so instead of solving nothing. let mut empty = ModelApp::new(Session::default()); empty.solve_all(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d21d67..9e072e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1758,12 +1758,13 @@ impl ModelApp { let shortcut = |modifiers, key| egui::KeyboardShortcut::new(modifiers, key); let shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; // Shift first: the plain shortcut matches it too. - let (redo, undo, save_as, save, open) = ctx.input_mut(|i| { + let (redo, undo, save_as, save, open, new) = ctx.input_mut(|i| { let redo = i.consume_shortcut(&shortcut(shift, egui::Key::Z)) || i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Y)); let undo = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::Z)); let save_as = i.consume_shortcut(&shortcut(shift, egui::Key::S)); let save = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::S)); - (redo, undo, save_as, save, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::O))) + let open = i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::O)); + (redo, undo, save_as, save, open, i.consume_shortcut(&shortcut(egui::Modifiers::COMMAND, egui::Key::N))) }); if redo { let _ = self.run("edit.redo", json!({})); @@ -1777,6 +1778,8 @@ impl ModelApp { } if open { self.open_dialog(None); + } else if new { + self.new_model(); } // Delete (or Backspace) deletes the definition selected in the browser, after asking. if self.prompt.is_none() diff --git a/docs/features.md b/docs/features.md index 1e9925c..9293ae6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -157,7 +157,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Panel | Capability | Status | Command | |---|---|---|---| | Model | Model Info — Node and element counts and the model summary | ready | `model.info`, `model.rename` | -| File | Open Model — Open a .fieldcraft model | ready | `file.open` | +| File | Open Model — Open a .fieldcraft model | ready | `file.open`, `file.new` | | File | Samples — Open a sample model, ready to solve | ready | `file.sample` | | File | Save — Save the model to its .fieldcraft file | ready | `file.save` | | File | Save As — Save the model to a new .fieldcraft file | ready | `file.save_as` |