Files
OneCAD-Simulation/crates/ui-egui/src/files.rs
T
jyvikoandClaude Opus 5.5 5011ae5896 ui: on the web, run a background job once its progress is shown
The browser build has no threads, so a background job (opening a file,
meshing from a form, solving) ran inline in the click and the page froze
before it said what it was doing. Now the work waits until the window
has drawn the job's progress, then runs on the window thread in the
next frame. Native jobs still start on a worker thread at once.

Part of #119

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-14 10:47:41 -04:00

1069 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<dyn Fn(&[Filter]) -> Option<String>>;
/// The host picks where to save, starting from a file name; `None` when the user cancelled.
pub type PickSave = Box<dyn Fn(&str, &[Filter]) -> Option<String>>;
/// 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<PickOpen>,
pub pick_save: Option<PickSave>,
/// The recent-files list's file (JSON); `None` keeps the list for this run only.
pub recent_path: Option<PathBuf>,
}
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<Kind> {
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> {
[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<String>,
file: Option<PathBuf>,
}
impl Recent {
/// The list kept in `file` (an unreadable or malformed file gives an empty list).
pub fn load(file: Option<PathBuf>) -> 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::<Vec<String>>(&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<String>,
/// 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<Session>, Result<Value, String>)>,
/// The work, while it waits to run on the window thread (the web, which has no threads).
pending: Option<Work>,
/// 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<dyn FnOnce() + Send>;
/// 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<Work> {
#[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<Session>, Result<Value, String>)>, Option<Work>) {
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, &params).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<String>) -> 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<Kind>) {
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<Value> {
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<String> = 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<String>, Vec<String>) = 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::<Vec<Value>>(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::<Vec<Value>>(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<Value> {
let key = egui::Id::new("fc_recover_entries");
if let Some(v) = ctx.data(|d| d.get_temp::<Vec<Value>>(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<Box<dyn FnOnce(&mut ModelApp)>> = 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<String>, offered: Rc<RefCell<Vec<Vec<Filter>>>>) -> 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<String> { 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<String> { panic!("a picker opened") }));
app.services.pick_save = Some(Box::new(|_: &str, _: &[Filter]| -> Option<String> { 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));
}
}