forked from 1b-agent/fieldcraft-upstream
ui: File › New starts an empty model (file.new, #144); an unknown sample name opens the samples (#200)
file.new replaces the session with an empty one: no design, mesh, definitions, results or journal, nothing to save, the undo history empty. The File menu and Cmd/Ctrl+N run it, asking first over unsaved changes. A sample name the app does not know (a typo, an old ?sample= link) opens the samples to choose from, and the status names it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
92b6225a72
commit
0625125ed7
7 files changed
+81
-5
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
let name: &str = req(p, "name")?;
|
||||
let names = || crate::sample::SAMPLES.iter().map(|x| x.name).collect::<Vec<_>>().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");
|
||||
|
||||
@@ -135,6 +135,7 @@ pub static ALL: &[CommandSpec] = &[
|
||||
define::STEP_ACTIVATE,
|
||||
define::RENAME,
|
||||
define::ACTIVITY,
|
||||
file::NEW,
|
||||
file::OPEN,
|
||||
file::SAVE,
|
||||
file::SAVE_AS,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Box<dyn FnOnce(&mut ModelApp)>> = 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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user