//! Files the user opens and saves: the host's native file pickers ([`Services`]), opening off the //! window thread, drag and drop, the recent-files list, and the prompts around them (unsaved //! changes, autosaved models to recover). //! //! Only user actions reach a picker: a click (toolbar, title bar, File menu), a shortcut or a drop. //! Programmatic calls (`engine.execute`, the control channel's `ui.start`) run commands with the //! paths they are given and never open a native dialog. Without a picker service (tests, the //! offscreen capture, the web), the same entries open their command's parameter form instead. //! //! A file opens on a copy of the session in a worker thread; the window keeps drawing and shows //! the file's progress with Cancel. The finished copy replaces the session only when the command //! succeeded, so a failed or cancelled open leaves the model as it was. Structure follows //! SolidCraft's `crates/ui-egui/src/file_load.rs` and `documents.rs` (MIT OR Apache-2.0), see //! ATTRIBUTION.md. use std::path::{Path, PathBuf}; use std::sync::mpsc::{Receiver, TryRecvError}; use fieldcraft_engine::Session; use serde_json::{Value, json}; use crate::ModelApp; /// A file type a picker offers: a name and its extensions (without the dot). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Filter { pub name: &'static str, pub extensions: &'static [&'static str], } /// The host picks a file to open from the filters (the first is selected), or `None` when the /// user cancelled. pub type PickOpen = Box Option>; /// The host picks where to save, starting from a file name; `None` when the user cancelled. pub type PickSave = Box Option>; /// What the host provides: native file pickers and where the recent-files list is kept. The /// desktop app installs them; tests answer the pickers with closures. #[derive(Default)] pub struct Services { pub pick_open: Option, pub pick_save: Option, /// The recent-files list's file (JSON); `None` keeps the list for this run only. pub recent_path: Option, } const MODEL: Filter = Filter { name: "FieldCraft model", extensions: &["fieldcraft"] }; const DESIGN: Filter = Filter { name: "SolidCraft design", extensions: &["solidcraft"] }; const MESH: Filter = Filter { name: "Face-tagged mesh (3MF)", extensions: &["3mf", "3MF"] }; const ANY: Filter = Filter { name: "Models, designs and tagged meshes", extensions: &["fieldcraft", "solidcraft", "3mf", "3MF"] }; /// The Open picker's filters: everything FieldCraft opens, then each type. pub const OPEN_FILTERS: &[Filter] = &[ANY, MODEL, DESIGN, MESH]; /// What opening a file does with it, from its extension. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { /// A `.fieldcraft` model replaces the session (`file.open`). Model, /// A `.solidcraft` design becomes the geometry (`geometry.open`). Design, /// A `.3mf` face-tagged mesh is added as a surface (`geometry.import.mesh`). Mesh, } impl Kind { /// The kind of a file by its extension (any case); `None` for a file FieldCraft does not open. pub fn of(path: &str) -> Option { let ext = Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); match ext.as_str() { "fieldcraft" => Some(Kind::Model), "solidcraft" => Some(Kind::Design), "3mf" => Some(Kind::Mesh), _ => None, } } pub fn command(self) -> &'static str { match self { Kind::Model => "file.open", Kind::Design => "geometry.open", Kind::Mesh => "geometry.import.mesh", } } /// The picker's filters when the user asked for this kind (its own type first). fn filters(self) -> &'static [Filter] { match self { Kind::Model => &[MODEL, ANY], Kind::Design => &[DESIGN, ANY], Kind::Mesh => &[MESH, ANY], } } /// What the window says while it opens and once it has. fn verbs(self) -> (&'static str, &'static str) { match self { Kind::Model | Kind::Design => ("Opening", "Opened"), Kind::Mesh => ("Importing", "Imported"), } } } /// The file a command opens, if it is one the File menu handles with a picker. fn kind_of_command(command: &str) -> Option { [Kind::Model, Kind::Design, Kind::Mesh].into_iter().find(|k| k.command() == command) } /// Commands a form runs in the background: they can take a while on a large part. pub const BACKGROUND: &[&str] = &["mesh.tet.generate", "mesh.hex.generate", "mesh.hex.sweep", "mesh.hex.mapped", "mesh.hex.blocks", "mesh.hex.octree"]; /// Most entries the recent-files list keeps, and the longest path it keeps. pub const MAX_RECENT: usize = 10; const MAX_RECENT_PATH: usize = 4096; /// Largest recent-files file read. const MAX_RECENT_BYTES: u64 = 256 * 1024; /// Recently opened and saved files, newest first. #[derive(Clone, Debug, Default, PartialEq)] pub struct Recent { pub paths: Vec, file: Option, } impl Recent { /// The list kept in `file` (an unreadable or malformed file gives an empty list). pub fn load(file: Option) -> Recent { let paths = file .as_ref() .filter(|f| std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() <= MAX_RECENT_BYTES)) .and_then(|f| std::fs::read(f).ok()) .and_then(|b| serde_json::from_slice::>(&b).ok()) .unwrap_or_default() .into_iter() .filter(|p| !p.is_empty() && p.len() <= MAX_RECENT_PATH && Kind::of(p).is_some()) .take(MAX_RECENT) .collect(); Recent { paths, file } } /// Put `path` first and save the list. pub fn add(&mut self, path: &str) { if path.is_empty() || path.len() > MAX_RECENT_PATH { return; } self.paths.retain(|p| p != path); self.paths.insert(0, path.to_string()); self.paths.truncate(MAX_RECENT); self.save(); } fn save(&self) { let Some(file) = &self.file else { return }; let write = || -> std::io::Result<()> { if let Some(dir) = file.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(file, serde_json::to_vec_pretty(&self.paths).unwrap_or_default()) }; if let Err(e) = write() { log::warn!("recent files {}: {e}", file.display()); } } } /// A command running in the background on a copy of the session: a file opening, a solve, a /// mesher started from its form. pub struct Loading { /// What the window shows while it runs ("Opening beam.fieldcraft"). pub title: String, /// The status when it succeeded ("Opened beam.fieldcraft"). done: String, /// The file it opens, for the recent list. recent: Option, /// Whether it brings a different model or new geometry, which the view then frames. fit: bool, /// The command form that started it: closed when it succeeds, showing the error when not. form: Option<&'static str>, /// The finished session copy and the command's reply. rx: Receiver<(Box, Result)>, /// The work, while it waits to run on the window thread (the web, which has no threads). pending: Option, /// Whether the window has drawn the job's progress: on the web the work runs (and the page /// stops drawing) only once the user has seen that it runs. shown: bool, } /// What waits for an answer about unsaved changes. #[derive(Clone, Debug, PartialEq)] pub enum Replace { /// A `.fieldcraft` file. File(String), /// A sample (`file.sample`). Sample(&'static str), } impl Replace { fn name(&self) -> String { match self { 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)), } } } /// A question the window asks before going on. #[derive(Clone, Debug, PartialEq)] pub enum Prompt { /// Opening a model or a sample discards the current one's unsaved changes. Unsaved(Replace), /// Autosaved models to recover (`file.recovery.list`). Recover, /// The samples to open (`file.sample`). Samples, } /// The answer to [`Prompt::Unsaved`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Answer { Save, Discard, Cancel, } /// The file name a model is saved under by default. fn model_name(session: &Session) -> String { session.path.as_deref().and_then(|p| Path::new(p).file_stem()).and_then(|s| s.to_str()).map_or_else(|| "Untitled".to_string(), str::to_string) } /// `path` with `ext` added when it has no extension of that name (some pickers leave it off). fn with_extension(path: String, ext: &str) -> String { let has = Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(ext)); if has { path } else { format!("{path}.{ext}") } } /// Run `work` off the window thread (inline on the web, which has no threads). fn off_thread(name: String, work: impl FnOnce() + Send + 'static) { if let Some(work) = start(name, Box::new(work)) { work(); } } /// A background job's work. type Work = Box; /// Start `work` on a worker thread. The web has no threads: the work comes back, to run on the /// window thread once the window has shown that it runs ([`Loading::shown`]). fn start(name: String, work: Work) -> Option { #[cfg(not(target_arch = "wasm32"))] { if let Err(e) = std::thread::Builder::new().name(name.clone()).spawn(work) { // The work went with the closure: a job then reports that it could not start. log::warn!("{name}: no worker thread: {e}"); } None } #[cfg(target_arch = "wasm32")] { let _ = name; Some(work) } } /// Run `command` on `session` off the window thread: the reply's receiver, and the work when it /// waits to run on the window thread (the web). fn spawn(session: Session, command: &'static str, params: Value) -> (Receiver<(Box, Result)>, Option) { let (tx, rx) = std::sync::mpsc::channel(); let work = start( format!("fieldcraft {command}"), Box::new(move || { let mut s = session; let r = s.execute(command, ¶ms).map_err(|e| e.to_string()); let _ = tx.send((Box::new(s), r)); }), ); (rx, work) } impl ModelApp { /// The model's name: its file's stem, or "Untitled". pub fn model_name(&self) -> String { model_name(&self.session) } /// What is running in the background ("Opening beam.fieldcraft", "Solving"), if anything. pub fn busy(&self) -> Option<&str> { self.loading.as_ref().map(|l| l.title.as_str()) } /// `Err` (and the status says so) while a background job runs: commands wait for it. pub fn ensure_idle(&mut self) -> Result<(), String> { let Some(busy) = self.busy() else { return Ok(()) }; let e = format!("{busy}: wait for it or cancel it"); self.set_status(e.clone(), true); Err(e) } /// Run `command` in the background; the session it ran on replaces this one when it /// succeeds. A command that brings a whole model (`fresh`: opening a model, recovering one) /// starts from an empty session, the others from a copy of this one. One at a time. fn begin_job(&mut self, command: &'static str, params: Value, fresh: bool, title: String, done: String, recent: Option) -> bool { if self.ensure_idle().is_err() { return false; } let base = if fresh { Session::default() } else { self.session.clone() }; let (rx, pending) = spawn(base, command, params); self.set_status(format!("{title}…"), false); let fit = fresh || recent.is_some(); self.loading = Some(Loading { title, done, recent, fit, form: None, rx, pending, shown: false }); true } /// The form's OK: run its command, in the background for the meshers ([`BACKGROUND`]) so the /// window keeps drawing (with Cancel) while a large part meshes; the form closes when the /// command succeeds and shows the error when it fails. pub fn submit_dialog(&mut self) { let Some(command) = self.dialog.as_ref().map(|d| d.command) else { return }; if !BACKGROUND.contains(&command) { let _ = self.run_dialog(); return; } let Ok((command, params)) = self.dialog_params() else { return }; let label = crate::command_label(command).to_string(); if self.begin_job(command, params, false, label.clone(), format!("{label} done"), None) && let Some(l) = self.loading.as_mut() { l.form = Some(command); } } /// File > Open: pick a model, design or tagged mesh and open it. `None` asks for any of them. /// Without a picker service the command's form opens instead. pub fn open_dialog(&mut self, kind: Option) { let Some(pick) = self.services.pick_open.as_ref() else { self.open_form(kind.unwrap_or(Kind::Model).command(), json!({})); return; }; if let Some(path) = pick(kind.map_or(OPEN_FILTERS, Kind::filters)) { self.open_path(&path); } } /// Whether replacing the model would lose work: unsaved changes made by commands. fn has_unsaved_work(&self) -> bool { self.session.is_dirty() && !self.session.journal.is_empty() } /// Open a file the user chose (picked, dropped or from the recent list) by its type. A model /// replacing unsaved changes asks first. pub fn open_path(&mut self, path: &str) { let Some(kind) = Kind::of(path) else { self.set_status(format!("{path}: FieldCraft opens .fieldcraft models, .solidcraft designs and .3mf tagged meshes"), true); return; }; if kind == Kind::Model && self.has_unsaved_work() { self.prompt = Some(Prompt::Unsaved(Replace::File(path.to_string()))); return; } self.load(kind, path.to_string()); } /// Open `path` in the background; files dropped while another opens wait their turn. fn load(&mut self, kind: Kind, path: String) { if self.loading.is_some() { self.queued.push_back(path); return; } let name = file_name(&path); let (doing, done) = kind.verbs(); self.begin_job(kind.command(), json!({"path": path}), kind == Kind::Model, format!("{doing} {name}"), format!("{done} {name}"), Some(path)); } /// Help › Samples (and the empty viewport): open a sample model, asking first when that /// 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); return; }; if self.has_unsaved_work() { self.prompt = Some(Prompt::Unsaved(Replace::Sample(sample.name))); return; } self.load_sample(sample.name); } 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() { self.model_changed(); self.fit_view(); let next = if self.session.model.steps().is_empty() { "" } else { " — Solve shows its results" }; self.set_status(format!("Opened the {} sample{next}", sample.label), false); } } /// Solve every step (`solve.run`) in the background; the results come up when it is done. pub fn solve_all(&mut self) { if self.session.model.steps().is_empty() { self.set_status("Nothing to solve: the model has no steps (Steps › Step, or open a sample from Help › Samples)", true); return; } let n = self.session.model.steps().len(); let done = format!("Solved {n} step{}", if n == 1 { "" } else { "s" }); self.begin_job("solve.run", json!({}), false, "Solving".into(), done, None); } /// Recover an autosaved model (`file.recover`) in the background. pub fn recover(&mut self, id: &str) { self.prompt = None; self.begin_job( "file.recover", json!({"id": id}), true, "Recovering the autosaved model".into(), "Recovered the autosaved model".into(), None, ); } /// Stop waiting for the background job; the model stays as it was. pub fn cancel_loading(&mut self) { if let Some(l) = self.loading.take() { self.queued.clear(); self.set_status(format!("Cancelled: {}", l.title), false); } } /// Take in a finished background job (called every frame); `true` while one is running. pub fn poll_loading(&mut self) -> bool { let Some(l) = &mut self.loading else { return false }; if l.pending.is_some() && !l.shown { return true; } if let Some(work) = l.pending.take() { work(); } let outcome = match l.rx.try_recv() { Ok(done) => Some(done), Err(TryRecvError::Empty) => return true, Err(TryRecvError::Disconnected) => None, }; let Some(l) = self.loading.take() else { return false }; match outcome { Some((session, Ok(reply))) => { // The session it replaces may be large: it is freed off the window thread. let old = std::mem::replace(&mut self.session, *session); off_thread("fieldcraft drop".into(), move || drop(old)); self.model_changed(); if l.fit { self.fit_view(); } if let Some(p) = &l.recent { self.recent.add(p); } if self.dialog.as_ref().is_some_and(|d| l.form == Some(d.command)) { self.dialog = None; } let warnings: Vec<&str> = reply.get("warnings").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect(); if warnings.is_empty() { self.set_status(l.done, false); } else { self.set_status(format!("{} — {}", l.done, warnings.join("; ")), true); } } Some((_, Err(e))) => { let what = l.recent.as_deref().map_or_else(|| l.title.clone(), file_name); self.set_status(format!("{what}: {e}"), true); if let Some(d) = self.dialog.as_mut().filter(|d| l.form == Some(d.command)) { d.error = Some(e); } } None => self.set_status(format!("{}: stopped unexpectedly", l.title), true), } if let Some(next) = self.queued.pop_front() { self.open_path(&next); } self.loading.is_some() } /// Block until the background open finishes (tests and the offscreen harness). pub fn wait_loaded(&mut self) { for _ in 0..6000 { if !self.poll_loading() { return; } std::thread::sleep(std::time::Duration::from_millis(10)); } } /// File > Save: to the model's file, or Save As when it has none. Returns whether it saved. pub fn save(&mut self) -> bool { if self.session.path.is_none() { return self.save_as(); } let saved = self.run("file.save", json!({})).is_ok(); self.saved_to_recent(saved) } /// After a save: the model's file goes first in the recent list. fn saved_to_recent(&mut self, saved: bool) -> bool { if saved && let Some(p) = self.session.path.clone() { self.recent.add(&p); } saved } /// File > Save As: pick a new file for the model. Without a picker the command's form opens. pub fn save_as(&mut self) -> bool { let Some(pick) = self.services.pick_save.as_ref() else { self.open_form("file.save_as", json!({})); return false; }; let Some(path) = pick(&format!("{}.fieldcraft", self.model_name()), &[MODEL]) else { return false }; let path = with_extension(path, "fieldcraft"); let saved = self.run("file.save_as", json!({"path": path})).is_ok(); self.saved_to_recent(saved) } /// Write Deck: pick where to write the model as a `format` deck. Without a picker the /// command's form opens with the format filled in. pub fn write_deck(&mut self, format: &str) { let Some(f) = fieldcraft_decks::format(format) else { self.set_status(format!("no {format} deck writer yet"), true); return; }; let Some(pick) = self.services.pick_save.as_ref() else { self.open_form("deck.write", json!({"format": format})); return; }; let exts: &'static [&'static str] = match f.extension { "inp" => &["inp"], "bdf" => &["bdf", "dat", "nas"], "k" => &["k", "key", "dyn"], _ => &[], }; let filter = Filter { name: "Solver deck", extensions: exts }; let Some(path) = pick(&format!("{}.{}", self.model_name(), f.extension), &[filter]) else { return }; let path = if exts.iter().any(|e| Path::new(&path).extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e))) { path } else { with_extension(path, f.extension) }; let _ = self.run("deck.write", json!({"format": format, "path": path})); } /// The autosaved models that can be recovered (`file.recovery.list`), newest first. pub fn recoverable(&mut self) -> Vec { self.session.execute("file.recovery.list", &json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default() } /// Answer the unsaved-changes prompt. pub fn answer_unsaved(&mut self, answer: Answer) { let Some(Prompt::Unsaved(next)) = self.prompt.clone() else { return }; self.prompt = None; let go = match answer { Answer::Save => self.save(), Answer::Discard => true, Answer::Cancel => false, }; if !go { return; } match next { Replace::File(path) => { if let Some(kind) = Kind::of(&path) { self.load(kind, path); } } Replace::Sample(name) => self.load_sample(name), } } /// What a click on a file entry does, when it is one: open, save, write a deck or recover /// (through the host's pickers when `pickers`), choose a sample, or solve in the background. /// `false` for every other command, and for picker entries without pickers (their form /// opens instead). pub(crate) fn file_action(&mut self, command: &str, preset: &Value, pickers: bool) -> bool { let can_save = pickers && self.services.pick_save.is_some(); match command { "file.save" if self.session.path.is_some() || can_save => { self.save(); } "file.save_as" if can_save => { self.save_as(); } "file.recover" | "file.recovery.list" => self.prompt = Some(Prompt::Recover), "file.sample" => self.prompt = Some(Prompt::Samples), "solve.run" => self.solve_all(), "deck.write" if can_save => match preset.get("format").and_then(Value::as_str) { Some(f) => self.write_deck(f), None => return false, }, other => match kind_of_command(other) { Some(kind) if pickers && self.services.pick_open.is_some() => self.open_dialog(Some(kind)), _ => return false, }, } true } /// Files dropped on the window (and by the control channel's `ui.drop`) open in turn; a /// hovered drop shows where it goes. pub(crate) fn dropped_files(&mut self, ctx: &egui::Context) { let mut dropped: Vec = ctx .input(|i| i.raw.dropped_files.iter().filter_map(|f| Some(f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty())).collect()); dropped.append(&mut self.synthetic_drops); // A model first: it replaces the session, then designs and meshes go into it. let (models, rest): (Vec, Vec) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model)); for p in models.into_iter().take(1).chain(rest) { self.open_path(&p); } } } /// The last part of a path, for messages. pub fn file_name(path: &str) -> String { Path::new(path).file_name().and_then(|n| n.to_str()).map_or_else(|| path.to_string(), str::to_string) } /// The prompts (unsaved changes, recover), the loading overlay and the drop hint. pub fn show(app: &mut ModelApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(app.ui.dark); let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); if hovering || app.loading.is_some() { let text = match app.loading.as_ref() { Some(l) => format!("{}…", l.title), None => "Drop to open: .fieldcraft models, .solidcraft designs, .3mf tagged meshes".into(), }; let mut cancel = false; egui::Area::new(egui::Id::new("fc_loading")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show( ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.overlay).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| { ui.horizontal(|ui| { if app.loading.is_some() { ui.spinner(); } ui.label(egui::RichText::new(text).color(t.overlay_text)); if app.loading.is_some() { cancel = ui.button("Cancel").clicked(); } }); }); }, ); if let Some(l) = app.loading.as_mut() { l.shown = true; } if cancel { app.cancel_loading(); } if app.loading.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } } match app.prompt.clone() { Some(Prompt::Unsaved(next)) => { let mut answer = None; crate::frame::window(ctx, "Unsaved changes", crate::frame::Width::Normal).id(egui::Id::new("fc_unsaved")).show(ctx, |ui| { ui.label(format!("Save the changes to {} before opening {}?", app.model_name(), next.name())); ui.label(egui::RichText::new("Without saving, they are lost.").color(t.text_dim)); ui.add_space(6.0); ui.horizontal(|ui| { if ui.button("Save").clicked() { answer = Some(Answer::Save); } if ui.button("Don't Save").clicked() { answer = Some(Answer::Discard); } if ui.button("Cancel").clicked() { answer = Some(Answer::Cancel); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { answer = Some(Answer::Cancel); } if let Some(a) = answer { app.answer_unsaved(a); } } Some(Prompt::Recover) => { let entries = app.recover_list(ctx); let (mut recover, mut discard, mut close) = (None, None, false); crate::frame::window(ctx, "Recover", crate::frame::Width::Wide).id(egui::Id::new("fc_recover")).show(ctx, |ui| { if entries.is_empty() { ui.label(egui::RichText::new("No autosaved models to recover.").color(t.text_dim)); } egui::Grid::new("fc_recover_grid").num_columns(3).spacing(egui::vec2(10.0, 4.0)).show(ui, |ui| { for e in &entries { let id = e.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); let name = e.get("path").and_then(Value::as_str).map_or_else(|| "Untitled".to_string(), file_name); let n = |k: &str| e.get(k).and_then(Value::as_u64).unwrap_or(0); ui.label(name); ui.label(egui::RichText::new(format!("{} nodes, {} elements", n("nodes"), n("elements"))).color(t.text_dim)); ui.horizontal(|ui| { if ui.button("Recover").clicked() { recover = Some(id.clone()); } if ui.button("Discard").clicked() { discard = Some(id.clone()); } }); ui.end_row(); } }); ui.add_space(6.0); close = ui.button("Close").clicked(); }); if let Some(id) = recover { app.recover(&id); } else if let Some(id) = discard { let _ = app.run("file.recovery.discard", json!({"id": id})); ctx.data_mut(|d| d.remove::>(egui::Id::new("fc_recover_entries"))); } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.prompt = None; ctx.data_mut(|d| d.remove::>(egui::Id::new("fc_recover_entries"))); } } Some(Prompt::Samples) => { let (mut open, mut close) = (None, false); crate::frame::window(ctx, "Samples", crate::frame::Width::Normal).id(egui::Id::new("fc_samples")).show(ctx, |ui| { sample_rows(ui, &t, &mut open); ui.add_space(6.0); close = ui.button("Close").clicked(); }); if let Some(name) = open { app.prompt = None; app.open_sample(name); } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.prompt = None; } } None => {} } } /// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked. pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) { let width = ui.available_width().clamp(280.0, 420.0); for sample in fieldcraft_engine::sample::SAMPLES { ui.horizontal(|ui| { ui.vertical(|ui| { ui.set_width(width - 70.0); ui.label(egui::RichText::new(sample.label).strong()); ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap()); }); if ui.button("Open").clicked() { *open = Some(sample.name); } }); ui.add_space(4.0); } } impl ModelApp { /// The recover window's entries, read from disk once while it is open. fn recover_list(&mut self, ctx: &egui::Context) -> Vec { let key = egui::Id::new("fc_recover_entries"); if let Some(v) = ctx.data(|d| d.get_temp::>(key)) { return v; } let v = self.recoverable(); ctx.data_mut(|d| d.insert_temp(key, v.clone())); v } } /// The File menu's rows: open, recent files, save, decks, samples, recover. The title bar's /// File menu. pub fn file_menu(app: &mut ModelApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(app.ui.dark); 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, "Open…", Some(&key("O"))) { act = Some(Box::new(|app| app.open_dialog(None))); } if row(ui, "Samples…", None) { act = Some(Box::new(|app| app.prompt = Some(Prompt::Samples))); } if !app.recent.paths.is_empty() { crate::frame::menu_heading(ui, &t, "RECENT"); for p in app.recent.paths.iter().take(6) { let name = file_name(p); if crate::frame::menu_row(ui, &t, &name, None, true).on_hover_text(p).clicked() { let p = p.clone(); act = Some(Box::new(move |app| app.open_path(&p))); } } } ui.separator(); if row(ui, "Save", Some(&key("S"))) { act = Some(Box::new(|app| { app.save(); })); } if row(ui, "Save As…", Some(&key("Shift+S"))) { act = Some(Box::new(|app| { app.save_as(); })); } ui.separator(); crate::frame::menu_heading(ui, &t, "WRITE DECK"); for c in fieldcraft_engine::catalogue::CATALOGUE.iter().filter(|c| c.panel == "Decks") { let format = c.preset().get("format").and_then(Value::as_str).map(str::to_string); let soon = c.milestone().map(|m| format!("soon · {m}")); let enabled = format.is_some() && soon.is_none(); if crate::frame::menu_row(ui, &t, &format!("{}…", c.label), soon.as_deref(), enabled).on_hover_text(c.tip).clicked() && let Some(f) = format { act = Some(Box::new(move |app| app.write_deck(&f))); } } ui.separator(); if crate::frame::menu_row(ui, &t, "Recover…", None, true) .on_hover_text("Models autosaved by an app that crashed or closed with unsaved changes") .clicked() { act = Some(Box::new(|app| app.prompt = Some(Prompt::Recover))); } if let Some(f) = act { app.ui.menu = None; ui.close(); f(app); } } #[cfg(test)] mod tests { use std::cell::RefCell; use std::rc::Rc; use super::*; fn temp(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("fieldcraft-files-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } /// A picker that answers `path` and records the filters it was offered. fn answering(path: Option, offered: Rc>>>) -> PickOpen { Box::new(move |f: &[Filter]| { offered.borrow_mut().push(f.to_vec()); path.clone() }) } #[test] fn file_types_dispatch_to_their_command() { assert_eq!(Kind::of("a/b.fieldcraft"), Some(Kind::Model)); assert_eq!(Kind::of("B.SOLIDCRAFT"), Some(Kind::Design)); assert_eq!(Kind::of("part.3MF").map(Kind::command), Some("geometry.import.mesh")); assert_eq!(Kind::of("part.step"), None); assert_eq!(Kind::of("noext"), None); assert_eq!(with_extension("a/b".into(), "fieldcraft"), "a/b.fieldcraft"); assert_eq!(with_extension("a/b.FIELDCRAFT".into(), "fieldcraft"), "a/b.FIELDCRAFT"); } /// Open (picker) → background open → the model, the recent list; Save As (picker) and Save. #[test] fn the_pickers_open_and_save_models() { let dir = temp("models"); let saved = dir.join("beam.fieldcraft").to_string_lossy().to_string(); let mut first = ModelApp::new(Session::default()); first.run_script(&fieldcraft_engine::sample::script()).unwrap(); // Save As through the save picker: the extension is added, the file becomes the model's. let bare = dir.join("beam").to_string_lossy().to_string(); first.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| { assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.fieldcraft", Some(&["fieldcraft"][..]))); Some(bare.clone()) })); first.start("file.save_as").unwrap(); assert_eq!(first.session.path.as_deref(), Some(saved.as_str())); assert!(!first.session.is_dirty()); assert_eq!(first.recent.paths, vec![saved.clone()]); let offered = Rc::new(RefCell::new(Vec::new())); let mut app = ModelApp::new(Session::default()); app.services.recent_path = None; app.services.pick_open = Some(answering(Some(saved.clone()), offered.clone())); app.start("file.open").unwrap(); assert!(app.dialog.is_none(), "a picker, not the form"); assert_eq!(offered.borrow()[0][0], MODEL, "Open Model offers models first"); assert_eq!(app.busy(), Some("Opening beam.fieldcraft")); app.wait_loaded(); assert_eq!(app.session.model.elements().len(), first.session.model.elements().len()); assert_eq!(app.session.path.as_deref(), Some(saved.as_str())); assert_eq!(app.recent.paths, vec![saved.clone()]); assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Opened beam.fieldcraft", false))); // Save goes to the model's own file without asking. app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option { panic!("Save asked for a file") })); app.run("mesh.node.add", serde_json::json!({"label": 99999, "xyz": [0, 0, 0]})).unwrap(); assert!(app.save()); assert!(!app.session.is_dirty()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn cancelled_pickers_and_failed_opens_leave_the_model() { let mut app = ModelApp::new(Session::default()); app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); let offered = Rc::new(RefCell::new(Vec::new())); app.services.pick_open = Some(answering(None, offered.clone())); app.open_dialog(None); assert_eq!(offered.borrow()[0], OPEN_FILTERS.to_vec(), "File > Open offers every type"); assert!(app.loading.is_none()); // A file that does not open: the model stays, the error is shown. let dir = temp("broken"); let broken = dir.join("broken.solidcraft"); std::fs::write(&broken, b"not a design").unwrap(); app.open_path(&broken.to_string_lossy()); app.wait_loaded(); assert!(app.session.model.node(1).is_some()); assert!(app.status.as_ref().is_some_and(|s| s.error && s.text.starts_with("broken.solidcraft:")), "{:?}", app.status); assert!(app.recent.paths.is_empty()); app.open_path("drawing.dxf"); assert!(app.status.as_ref().is_some_and(|s| s.error)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn opening_a_model_over_unsaved_changes_asks_first() { let dir = temp("unsaved"); let path = dir.join("other.fieldcraft").to_string_lossy().to_string(); let mut other = ModelApp::new(Session::default()); other.run("mesh.node.add", serde_json::json!({"label": 5, "xyz": [0, 0, 0]})).unwrap(); other.run("file.save_as", serde_json::json!({"path": path})).unwrap(); let mut app = ModelApp::new(Session::default()); app.run("mesh.node.add", serde_json::json!({"label": 1, "xyz": [0, 0, 0]})).unwrap(); app.open_path(&path); assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::File(path.clone())))); assert!(app.loading.is_none()); app.answer_unsaved(Answer::Cancel); assert!(app.prompt.is_none() && app.loading.is_none() && app.session.model.node(1).is_some()); app.open_path(&path); app.answer_unsaved(Answer::Discard); app.wait_loaded(); assert!(app.session.model.node(5).is_some() && app.session.model.node(1).is_none()); let _ = std::fs::remove_dir_all(&dir); } #[test] fn decks_are_written_where_the_picker_says() { let dir = temp("decks"); let target = dir.join("cantilever").to_string_lossy().to_string(); let mut app = ModelApp::new(Session::default()); app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); let t = target.clone(); app.services.pick_save = Some(Box::new(move |name: &str, f: &[Filter]| { assert_eq!((name, f.first().map(|f| f.extensions)), ("Untitled.inp", Some(&["inp"][..]))); Some(t.clone()) })); app.start("deck.write.abaqus").unwrap(); assert!(app.dialog.is_none()); let text = std::fs::read_to_string(format!("{target}.inp")).unwrap(); assert!(text.contains("*NODE") || text.contains("*Node"), "{}", &text[..text.len().min(200)]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn the_recent_list_is_kept_newest_first_and_survives_bad_files() { let dir = temp("recent"); let file = dir.join("recent.json"); let mut r = Recent::load(Some(file.clone())); assert!(r.paths.is_empty()); for i in 0..12 { r.add(&format!("/m/{i}.fieldcraft")); } r.add("/m/3.fieldcraft"); let again = Recent::load(Some(file.clone())); assert_eq!(again.paths.len(), MAX_RECENT); assert_eq!(again.paths.first().map(String::as_str), Some("/m/3.fieldcraft")); assert_eq!(again.paths.iter().filter(|p| *p == "/m/3.fieldcraft").count(), 1); std::fs::write(&file, b"{not json").unwrap(); assert!(Recent::load(Some(file.clone())).paths.is_empty()); std::fs::write(&file, br#"["/x/a.fieldcraft", "/x/b.exe", ""]"#).unwrap(); assert_eq!(Recent::load(Some(file)).paths, vec!["/x/a.fieldcraft".to_string()]); let _ = std::fs::remove_dir_all(&dir); } /// A sample opens with nothing to save; Solve runs its steps in the background and the /// Results tab comes up with the first step's result. #[test] fn a_sample_solves_into_the_results_view() { let mut app = ModelApp::new(Session::default()); app.start("file.sample").unwrap(); assert_eq!(app.prompt, Some(Prompt::Samples)); app.prompt = None; app.open_sample("cantilever"); assert!(!app.session.is_dirty() && !app.session.model.steps().is_empty()); app.start("solve.run").unwrap(); assert_eq!(app.busy(), Some("Solving")); assert!(app.run("model.info", serde_json::json!({})).is_err(), "commands wait for the solve"); app.wait_loaded(); assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Solved 2 steps")); app.logic(&egui::Context::default()); assert_eq!(app.ui.tab, fieldcraft_engine::catalogue::Tab::Results); assert!(app.plot().is_some()); // Over unsaved changes, opening a sample asks first. app.run("mesh.node.add", serde_json::json!({"label": 900000, "xyz": [0, 0, 0]})).unwrap(); app.open_sample("impact"); assert_eq!(app.prompt, Some(Prompt::Unsaved(Replace::Sample("impact")))); app.answer_unsaved(Answer::Discard); assert!(app.session.model.step("Impact").is_some()); // A model without steps says so instead of solving nothing. let mut empty = ModelApp::new(Session::default()); empty.solve_all(); assert!(empty.busy().is_none() && empty.status.as_ref().is_some_and(|s| s.error)); } /// A first-time user's path: Open picks a SolidCraft design, the Tet Mesh button opens its /// form, OK with the defaults meshes it in the background, and the mesh is there. A mesher /// that does not fit says why in the form, which stays open. #[test] fn a_picked_design_meshes_from_the_ribbon_with_the_defaults() { let dir = temp("path"); let design = dir.join("plate.solidcraft").to_string_lossy().to_string(); std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); let offered = Rc::new(RefCell::new(Vec::new())); let mut app = ModelApp::new(Session::default()); app.services.pick_open = Some(answering(Some(design.clone()), offered)); app.open_dialog(None); app.wait_loaded(); assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1), "{:?}", app.status); assert!(!app.display().bounds.is_empty()); app.start("mesh.tet.generate").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| (d.command, d.text.as_str())), Some(("mesh.tet.generate", "{}"))); app.submit_dialog(); assert_eq!(app.busy(), Some("Generate Tet Mesh"), "meshing runs off the window thread"); app.wait_loaded(); assert!(app.dialog.is_none(), "the form closes when it succeeded: {:?}", app.status); assert_eq!(app.status.as_ref().map(|s| (s.text.as_str(), s.error)), Some(("Generate Tet Mesh done", false))); let counts = app.session.model.element_counts(); assert!(matches!(counts.as_slice(), [(fieldcraft_model::ElementKind::Tet4, n)] if *n > 0), "{counts:?}"); assert_eq!(app.session.journal.len(), 2, "open and mesh, each an undo step"); // A plate with a hole is not one mapped block: the form stays with the reason. app.start("mesh.hex.mapped").unwrap(); app.submit_dialog(); app.wait_loaded(); let error = app.dialog.as_ref().and_then(|d| d.error.clone()).unwrap_or_default(); assert!(error.contains("not a logical cube"), "{error}"); assert!(app.status.as_ref().is_some_and(|s| s.error)); assert_eq!(app.session.model.element_counts(), counts, "a failed mesher changes nothing"); let _ = std::fs::remove_dir_all(&dir); } /// The control channel's `ui.start` never opens a picker: it opens the form. #[test] fn programmatic_starts_never_open_a_picker() { let mut app = ModelApp::new(Session::default()); app.services.pick_open = Some(Box::new(|_: &[Filter]| -> Option { panic!("a picker opened") })); app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option { panic!("a picker opened") })); app.start_programmatic("file.open").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("file.open")); app.start_programmatic("deck.write.abaqus").unwrap(); assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("deck.write")); // In-window choices are not pickers: they behave as a click. app.start_programmatic("file.sample").unwrap(); assert_eq!(app.prompt, Some(Prompt::Samples)); } }