M1: save a copy as .fig

Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.

Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
This commit is contained in:
Skyfuller1303
2026-10-16 02:03:46 +02:00
parent 4ee413acde
commit d4394e87cc
12 changed files with 835 additions and 20 deletions
+35 -1
View File
@@ -223,12 +223,25 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
/// Native Save as filters; other callers keep their existing combined filter.
fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
if exts == ["figcraft", "fig"] {
vec![("FigCraft (.figcraft)", &exts[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &exts[1..])]
} else {
vec![("File", exts)]
}
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
let mut dialog = dialog(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
@@ -297,3 +310,24 @@ pub fn window_icon() -> Option<egui::IconData> {
let (w, h) = img.dimensions();
Some(egui::IconData { rgba: img.into_raw(), width: w, height: h })
}
#[cfg(test)]
mod save_filter_tests {
use super::save_filters;
#[test]
fn native_and_fig_save_filters_are_separate_with_native_first() {
let extensions = ["figcraft", "fig"];
assert_eq!(
save_filters(&extensions),
vec![("FigCraft (.figcraft)", &extensions[..1]), (figcraft_ui_egui::i18n::tr(".fig (experimental)"), &extensions[1..])]
);
}
#[test]
fn other_save_filters_keep_one_combined_group() {
for extensions in [&["figcraft"][..], &["fig"][..], &["png"][..], &["png", "jpg", "webp"][..], &[][..]] {
assert_eq!(save_filters(extensions), vec![("File", extensions)]);
}
}
}
+32 -3
View File
@@ -41,8 +41,20 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open_recent
),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
spec!(
"file.saveAs",
"Save as",
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save_as
),
spec!(
"file.export",
"Export",
@@ -775,8 +787,20 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"profile": profile.id(), "changed": changed}))
}
/// Save the file of the active tab (the main file for a branch tab, with its branches).
/// Native saves act on the current document; a `.fig` copy exports the version shown, and a
/// pending Save As choice must leave it shown until its destination is known.
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
let path =
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
path.is_some_and(|path| ext(&path) != "fig")
}
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
/// the active document as an experimental `.fig` copy without changing its native save state.
fn save_to(s: &mut Session, path: String) -> Result<Value> {
if ext(&path) == "fig" {
return export_fig(s, &json!({"path": path}));
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
let bytes = file_bytes(ds)?;
@@ -795,6 +819,11 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
return save_to(s, path);
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, path),
None => {
s.request_ui("dialog.saveAs", json!({}));
+5 -1
View File
@@ -390,7 +390,8 @@ pub struct VersionPreview {
/// Why edits are refused while a version is shown.
pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit";
/// Commands that first end a version preview: they act on the document itself.
/// Commands that first end a version preview: they act on the document itself. Saving does so
/// only with a native destination; export copies and pending format choices keep the preview.
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -870,6 +871,7 @@ impl Session {
// Every other command acts on the document itself, not on a live text preview.
textpreview::end(self);
if ENDS_PREVIEW.contains(&id)
&& (!matches!(id, "file.save" | "file.saveAs") || cmd::file::save_ends_preview(self, id, params))
&& let Ok(ds) = self.ds_mut()
{
ds.end_preview();
@@ -1642,6 +1644,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod rotation_origin_tests;
#[cfg(test)]
mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
#[cfg(test)]
mod section_contents_tests;
+366
View File
@@ -0,0 +1,366 @@
//! Save As can write an experimental `.fig` copy without replacing native save state.
use std::path::PathBuf;
use std::sync::Arc;
use figcraft_format::Version;
use serde_json::{Value, json};
use crate::{DocState, EngineError, Session};
struct SaveFiles {
dir: PathBuf,
files: Vec<PathBuf>,
}
impl SaveFiles {
fn new() -> Self {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
Self { dir, files: Vec::new() }
}
fn path(&mut self, name: &str) -> PathBuf {
let path = self.dir.join(name);
self.files.push(path.clone());
path
}
}
impl Drop for SaveFiles {
fn drop(&mut self) {
for path in &self.files {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_dir(path);
}
let _ = std::fs::remove_dir(&self.dir);
}
}
fn rect(s: &mut Session, name: &str) {
s.execute("shape.rectangle", &json!({"name": name, "x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
}
fn assert_versions_unchanged(after: &[Version], before: &[Version]) {
assert_eq!(after.len(), before.len());
for (after, before) in after.iter().zip(before) {
assert_eq!(
(&after.id, &after.name, &after.description, after.time, after.auto),
(&before.id, &before.name, &before.description, before.time, before.auto)
);
assert_eq!(*after.document().unwrap(), *before.document().unwrap());
}
}
fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]) {
let after = s.ds().unwrap();
assert_eq!(after.path, before.path);
assert_eq!(after.is_dirty(), before.is_dirty());
assert_eq!(after.versions_changed, before.versions_changed);
assert!(Arc::ptr_eq(&after.doc, &before.doc));
assert!(Arc::ptr_eq(&after.saved_doc, &before.saved_doc));
assert_eq!(
after.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page)),
before.preview.as_ref().map(|p| (&p.id, &p.name, p.time, &p.selection, p.page))
);
if let (Some(after), Some(before)) = (&after.preview, &before.preview) {
assert!(Arc::ptr_eq(&after.doc, &before.doc));
}
assert_eq!((&after.selection, after.page), (&before.selection, before.page));
assert_versions_unchanged(&after.versions, &before.versions);
assert_eq!(after.branches.len(), before.branches.len());
for (after, before) in after.branches.iter().zip(&before.branches) {
assert_eq!(
(&after.id, &after.name, after.time, after.client, after.state),
(&before.id, &before.name, before.time, before.client, before.state)
);
assert_eq!(*after.base, *before.base);
assert_eq!(*after.head, *before.head);
assert_versions_unchanged(&after.versions, &before.versions);
}
for (after, before) in [(&after.history.undo, &before.history.undo), (&after.history.redo, &before.history.redo)] {
assert_eq!(after.len(), before.len());
for (after, before) in after.iter().zip(before) {
assert_eq!((&after.label, &after.selection, after.page), (&before.label, &before.selection, before.page));
assert!(Arc::ptr_eq(&after.doc, &before.doc));
}
}
assert_eq!(s.prefs.recent_files, recent);
}
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
let bytes = std::fs::read(path).unwrap();
assert_eq!(result["format"], "fig");
assert_eq!(result["bytes"], bytes.len());
assert!(result["warnings"].is_array());
assert!(figcraft_format::load_bytes(&bytes).is_err(), "a .fig copy must not contain native FigCraft data");
figcraft_format::fig::import(&bytes, "Copy").unwrap().0
}
#[test]
fn fig_save_as_keeps_native_path_dirty_state_and_history() {
let mut files = SaveFiles::new();
let native = files.path("design.figcraft");
let copy = files.path("copy.fig");
let mut s = Session::new();
rect(&mut s, "Saved rectangle");
s.execute("file.saveAs", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
rect(&mut s, "New rectangle");
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Copy warning"})).unwrap();
s.execute("file.saveVersion", &json!({"name": "Before copy"})).unwrap();
rect(&mut s, "Undone rectangle");
s.undo().unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(before.is_dirty());
assert!(!before.history.redo.is_empty());
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert!(imported.nodes().any(|n| n.name == "New rectangle"));
assert!(!imported.nodes().any(|n| n.name == "Undone rectangle"));
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w == "comments were left out"));
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("file.save", &json!({})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(persisted.doc.nodes().any(|n| n.name == "New rectangle"));
assert_eq!(persisted.versions[0].name, "Before copy");
assert_eq!(std::fs::read(&copy).unwrap().len(), result["bytes"].as_u64().unwrap() as usize);
}
#[test]
fn unsaved_fig_copy_remains_unsaved() {
let mut files = SaveFiles::new();
let copy = files.path("unsaved.fig");
let mut s = Session::new();
rect(&mut s, "Unsaved rectangle");
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == "Unsaved rectangle"));
assert_save_state_unchanged(&s, &before, &recent);
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
}
#[test]
fn empty_document_fig_copy_keeps_clean_state() {
let mut files = SaveFiles::new();
let copy = files.path("empty.fig");
let mut s = Session::new();
let before = s.ds().unwrap().clone();
assert!(!before.is_dirty());
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert_eq!(imported.pages.len(), 1);
assert_eq!(imported.node_count(), 1);
assert_save_state_unchanged(&s, &before, &[]);
}
#[test]
fn fig_save_commands_accept_uppercase_extension() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let copy = files.path(&format!("copy-{index}.FIG"));
let mut s = Session::new();
rect(&mut s, command);
let before = s.ds().unwrap().clone();
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == command));
assert_save_state_unchanged(&s, &before, &[]);
}
}
#[test]
fn fig_copy_from_branch_exports_active_document_without_saving_main() {
let mut files = SaveFiles::new();
let native = files.path("main.figcraft");
let copy = files.path("branch.fig");
let mut s = Session::new();
rect(&mut s, "Main rectangle");
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
rect(&mut s, "Branch rectangle");
s.execute("file.saveVersion", &json!({"name": "Branch version"})).unwrap();
// Branch edits normally sync to main before commands; capture that state before exporting.
s.execute("branch.list", &json!({})).unwrap();
let branch = s.ds().unwrap().clone();
let main = s.docs[0].clone();
let recent = s.prefs.recent_files.clone();
let result = s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
assert!(read_fig(&copy, &result).nodes().any(|n| n.name == "Branch rectangle"));
assert_save_state_unchanged(&s, &branch, &recent);
assert_eq!(s.docs[0].path, main.path);
assert_eq!(s.docs[0].is_dirty(), main.is_dirty());
assert!(Arc::ptr_eq(&s.docs[0].saved_doc, &main.saved_doc));
assert!(Arc::ptr_eq(&s.docs[0].doc, &main.doc));
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("file.save", &json!({})).unwrap();
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
assert!(!s.ds().unwrap().is_dirty());
assert!(!s.docs[0].is_dirty());
}
#[test]
fn fig_copy_write_failure_keeps_save_state() {
let mut files = SaveFiles::new();
let blocked = files.path("blocked.fig");
std::fs::create_dir(&blocked).unwrap();
let missing = files.path("missing/copy.fig");
let mut s = Session::new();
rect(&mut s, "Kept rectangle");
s.execute("file.saveVersion", &json!({"name": "Kept version"})).unwrap();
rect(&mut s, "Later rectangle");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
for command in ["file.save", "file.saveAs"] {
for path in [&blocked, &missing] {
assert!(s.execute(command, &json!({"path": path.to_string_lossy()})).is_err());
assert_save_state_unchanged(&s, &before, &[]);
assert!(!PathBuf::from(format!("{}.tmp-figcraft", path.display())).exists());
}
}
}
#[test]
fn save_as_without_extension_stays_native() {
let mut files = SaveFiles::new();
let requested = files.dir.join("native");
let native = files.path("native.figcraft");
let mut s = Session::new();
rect(&mut s, "Native rectangle");
let result = s.execute("file.saveAs", &json!({"path": requested.to_string_lossy()})).unwrap();
assert_eq!(result["path"], native.to_string_lossy().as_ref());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
assert!(!s.ds().unwrap().is_dirty());
assert!(!requested.exists());
let loaded = figcraft_format::load_bytes(&std::fs::read(native).unwrap()).unwrap();
assert!(loaded.0.nodes().any(|n| n.name == "Native rectangle"));
}
#[test]
fn fig_copy_of_version_preview_keeps_visible_version_and_save_state() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let native = files.path(&format!("current-{index}.figcraft"));
let copy = files.path(&format!("version-{index}.FIG"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
let saved = std::fs::read(&native).unwrap();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(before.preview.is_some());
assert!(before.is_dirty());
let result = s.execute(command, &json!({"path": copy.to_string_lossy()})).unwrap();
let imported = read_fig(&copy, &result);
assert!(imported.nodes().any(|n| n.name == "A"));
assert!(!imported.nodes().any(|n| n.name == "B"), "the copy must export the version shown");
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&native).unwrap(), saved);
s.execute("version.exitPreview", &json!({})).unwrap();
assert!(s.doc().unwrap().nodes().any(|n| n.name == "B"));
}
}
#[test]
fn pending_save_choice_keeps_version_preview() {
let mut files = SaveFiles::new();
for (index, (command, native_path)) in [("file.saveAs", false), ("file.saveAs", true), ("file.save", false)].into_iter().enumerate() {
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
if native_path {
let native = files.path(&format!("saved-{index}.figcraft"));
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
}
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(matches!(s.execute(command, &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
assert_save_state_unchanged(&s, &before, &recent);
}
}
#[test]
fn native_save_from_version_preview_writes_current_document() {
let mut files = SaveFiles::new();
for (index, (command, explicit)) in [("file.save", false), ("file.save", true), ("file.saveAs", true)].into_iter().enumerate() {
let native = files.path(&format!("native-version-{index}.figcraft"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
s.execute("file.save", &json!({"path": native.to_string_lossy()})).unwrap();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let undo = s.ds().unwrap().history.undo.len();
let params = if explicit { json!({"path": native.to_string_lossy()}) } else { json!({}) };
s.execute(command, &params).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
assert!(!loaded.versions[0].document().unwrap().nodes().any(|n| n.name == "B"));
}
}
#[test]
fn implicit_save_protects_legacy_fig_destination() {
let mut files = SaveFiles::new();
for (index, command) in ["file.save", "file.saveAs"].into_iter().enumerate() {
let legacy = files.path(&format!("legacy-{index}.FIG"));
let native = files.path(&format!("corrected-{index}.figcraft"));
let mut s = Session::new();
rect(&mut s, "A");
s.execute("file.saveVersion", &json!({"name": "A only"})).unwrap();
// Earlier builds could write native data under .fig; keep those bytes safe on plain Save.
let sentinel = crate::cmd::file::file_bytes(s.ds().unwrap()).unwrap();
std::fs::write(&legacy, &sentinel).unwrap();
s.ds_mut().unwrap().path = Some(legacy.to_string_lossy().into_owned());
s.ds_mut().unwrap().mark_saved();
rect(&mut s, "B");
s.execute("version.preview", &json!({"index": 0})).unwrap();
let before = s.ds().unwrap().clone();
let recent = s.prefs.recent_files.clone();
assert!(matches!(s.execute("file.save", &json!({})), Err(EngineError::Disabled(..))));
assert_eq!(s.ui_requests.last().unwrap().kind, "dialog.saveAs");
assert_save_state_unchanged(&s, &before, &recent);
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
s.execute(command, &json!({"path": native.to_string_lossy()})).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path.as_deref(), native.to_str());
let loaded = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.doc.nodes().any(|n| n.name == "A"));
assert!(loaded.doc.nodes().any(|n| n.name == "B"));
assert_eq!(loaded.versions[0].name, "A only");
rect(&mut s, "C");
s.execute("file.save", &json!({})).unwrap();
let loaded = figcraft_format::load_bytes(&std::fs::read(&native).unwrap()).unwrap();
assert!(loaded.0.nodes().any(|n| n.name == "C"));
assert_eq!(std::fs::read(&legacy).unwrap(), sentinel);
}
}
+2 -2
View File
@@ -250,8 +250,8 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"save_file",
"Save file",
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it.",
obj(json!({"path": string("Output .figcraft path")}), &[]),
"Save the active document as a .figcraft file. `path` is required the first time (an untitled document has no path); later saves reuse it. A `path` ending in .fig writes an experimental .fig export copy instead and leaves the document's own path and unsaved changes alone.",
obj(json!({"path": string("Output .figcraft path, or a .fig path for an experimental export copy")}), &[]),
false,
),
tool("undo", "Undo", "Undo the last change (one command, batch or gesture). Returns {undone: bool}.", empty(), false),
+2
View File
@@ -17,6 +17,7 @@ status = "draft — needs native review"
"1…9, 0" = "1…9, 0"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
"About" = "Über"
@@ -1347,6 +1348,7 @@ status = "draft — needs native review"
"Use as mask" = "Als Maske verwenden"
"Use auto layout" = "Auto-Layout verwenden"
"Use number keys for opacity" = "Zifferntasten für Deckkraft verwenden"
"Use the .figcraft or .fig extension." = "Verwenden Sie die Dateiendung .figcraft oder .fig."
"Use the layer's fill" = "Füllung der Ebene verwenden"
"Used in {files} · {instances}" = "Verwendet in {files} · {instances}"
"Username" = "Benutzername"
+2
View File
@@ -16,6 +16,7 @@ name = "English"
"1…9, 0" = "1…9, 0"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame"
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
@@ -1413,6 +1414,7 @@ name = "English"
"Use as mask" = "Use as mask"
"Use auto layout" = "Use auto layout"
"Use number keys for opacity" = "Use number keys for opacity"
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
"Use the layer's fill" = "Use the layer's fill"
"Used in {files} · {instances}" = "Used in {files} · {instances}"
"Username" = "Username"
+2
View File
@@ -9,6 +9,7 @@ status = "draft — needs native review"
[ui]
"Couldn't download the variables" = "No se pudieron descargar las variables"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Ya existe un archivo .figcraft con este nombre. Elige su nombre completo para confirmar que deseas reemplazarlo."
"% of all commits" = "% del total de commits"
"(no longer published)" = "(ya no publicado)"
@@ -1414,6 +1415,7 @@ status = "draft — needs native review"
"Use as mask" = "Usar como máscara"
"Use auto layout" = "Usar diseño automático"
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
"Use the layer's fill" = "Usar el relleno de la capa"
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
"Username" = "Usuario"
+27 -7
View File
@@ -1,11 +1,13 @@
//! Dialogs: About, Preferences, keyboard shortcuts, find & replace, rename layers, variables,
//! export.
use std::path::Path;
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_color::ColorProfile;
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId};
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
use serde_json::json;
use serde_json::{Value, json};
use crate::FigApp;
use crate::export_panel::{export_layers, save_one, suggested_name};
@@ -505,17 +507,35 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) {
}
}
/// A `.fig` Save as result is an export; only native saves report that the document was saved.
pub(crate) fn file_saved(app: &mut FigApp, result: &Value) {
if result["format"] == "fig" {
fig_exported(app, result);
} else {
app.toast(trf("Saved {name}", &[("name", &result_file_name(result))]));
}
}
fn result_file_name(result: &Value) -> String {
result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default()
}
/// The `.fig` result stays an export: native save state is unchanged, and omissions are reported.
pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) {
let file = result_file_name(result);
let left_out = result["warnings"].as_array().map_or(0, Vec::len);
app.toast(match left_out {
0 => trf("Exported {name}", &[("name", &file)]),
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
});
}
/// Save the whole file as `.fig`; the toast says how much was left out.
fn save_fig(app: &mut FigApp, name: &str) {
let filename = format!("{name}.fig");
app.pick_save(tr("Export"), &filename, &["fig"], |app, p| {
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) {
let left_out = r["warnings"].as_array().map_or(0, Vec::len);
app.toast(match left_out {
0 => trf("Exported {name}", &[("name", &file)]),
n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]),
});
fig_exported(app, &r);
}
});
}
+35 -6
View File
@@ -162,6 +162,8 @@ mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod save_as_tests;
#[cfg(test)]
mod select_all_tests;
#[cfg(test)]
mod shortcut_keys_tests;
@@ -938,11 +940,31 @@ impl FigApp {
}
pub fn save_as_dialog(&mut self) {
let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into());
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| {
let ps = p.to_string_lossy().to_string();
if app.run("file.saveAs", json!({"path": ps})).is_some() {
app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return };
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| {
if p.extension().is_none_or(|e| e.is_empty()) {
if !p.set_extension("figcraft") {
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
return;
}
// This name wasn't confirmed by the system dialog: don't replace an existing file.
match p.try_exists() {
Ok(false) => {}
Ok(true) => {
app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement."));
return;
}
Err(e) => {
app.toast(e.to_string());
return;
}
}
} else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) {
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
return;
}
if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) {
dialogs::file_saved(app, &r);
}
});
}
@@ -950,7 +972,14 @@ impl FigApp {
pub fn save(&mut self) {
// A branch tab saves its main file.
let tab = figcraft_engine::branches::file_tab(&self.session);
if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) {
// A path ending in .fig is never saved over silently: a .fig destination is an export copy.
let native_path = self
.session
.docs
.get(tab)
.and_then(|d| d.path.as_deref())
.is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig")));
if native_path {
if self.run("file.save", json!({})).is_some() {
self.toast(i18n::tr("Saved"));
}
+320
View File
@@ -0,0 +1,320 @@
//! Native Save as accepts both file formats; .fig copies preserve native save state.
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use serde_json::json;
use crate::dialogs::file_saved;
use crate::{DialogFuture, FigApp, Services};
type Picks = Arc<Mutex<Vec<(String, String, Vec<String>)>>>;
struct Picker {
path: Option<PathBuf>,
picks: Picks,
}
impl Services for Picker {
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
self.picks.lock().unwrap().push((title.into(), name.into(), exts.iter().map(|e| (*e).into()).collect()));
Box::pin(std::future::ready(self.path.take()))
}
}
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("figcraft-save-as-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Temp {
fn drop(&mut self) {
if let Ok(files) = std::fs::read_dir(&self.0) {
for file in files.flatten() {
let _ = std::fs::remove_file(file.path());
}
}
let _ = std::fs::remove_dir(&self.0);
}
}
fn session() -> Session {
let mut s = Session::new();
s.doc_mut().unwrap().name = "Prototype".into();
s.execute("shape.rectangle", &json!({"width": 100, "height": 80})).unwrap();
s
}
fn picker(path: Option<PathBuf>) -> (Picker, Picks) {
let picks = Picks::default();
(Picker { path, picks: picks.clone() }, picks)
}
fn app(path: Option<PathBuf>) -> (FigApp, Picks) {
let (picker, picks) = picker(path);
(FigApp::new(session(), Box::new(picker)), picks)
}
fn toast(app: &FigApp) -> &str {
&app.ui.toast.as_ref().unwrap().0
}
fn expected_picker() -> Vec<(String, String, Vec<String>)> {
vec![("Save as".into(), "Prototype.figcraft".into(), vec!["figcraft".into(), "fig".into()])]
}
#[test]
fn save_as_menu_and_first_save_open_the_platform_picker_directly_with_both_extensions() {
for action in ["direct", "menu", "first"] {
let (mut app, picks) = app(None);
let before = app.session.doc().unwrap().clone();
match action {
"direct" => app.save_as_dialog(),
"menu" => crate::menus::invoke(&mut app, "file.saveAs"),
"first" => app.save(),
_ => panic!("unknown save action {action}"),
}
app.poll_dialog(&egui::Context::default());
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert_eq!(app.session.doc().unwrap(), &before);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
assert!(app.ui.toast.is_none());
}
}
#[test]
fn a_pathless_engine_request_uses_the_same_platform_picker() {
let (mut app, picks) = app(None);
let before = app.session.doc().unwrap().clone();
assert!(app.run("file.saveAs", json!({})).is_none(), "the engine requests the UI and refuses a pathless programmatic save");
app.poll_dialog(&egui::Context::default());
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert_eq!(app.session.doc().unwrap(), &before);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
}
#[test]
fn native_paths_and_an_extensionless_default_save_native_files_and_show_the_written_name() {
let temp = Temp::new();
for (chosen, written) in [("native", "native.figcraft"), ("saved.figcraft", "saved.figcraft"), ("upper.FIGCRAFT", "upper.FIGCRAFT")] {
let (mut app, picks) = app(Some(temp.path(chosen)));
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
let path = temp.path(written);
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert!(path.is_file());
assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str());
assert!(!app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), format!("Saved {written}"));
let mut reopened = Session::empty();
reopened.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(reopened.doc().unwrap().nodes().count(), app.session.doc().unwrap().nodes().count());
assert_eq!(reopened.doc().unwrap().name, "Prototype");
}
}
#[test]
fn returned_fig_paths_export_real_copies_without_changing_native_save_state() {
let temp = Temp::new();
for name in ["copy.fig", "upper.FIG"] {
let path = temp.path(name);
let (mut app, picks) = app(Some(path.clone()));
let native = temp.path("working.figcraft").to_string_lossy().into_owned();
app.session.ds_mut().unwrap().path = Some(native.clone());
let before = app.session.doc().unwrap().clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert!(path.is_file());
assert_eq!(app.session.doc().unwrap(), &before);
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(native.as_str()));
assert!(app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), format!("Exported {name}"));
let mut reopened = Session::empty();
reopened.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(reopened.doc().unwrap().nodes().count(), before.nodes().count());
}
}
#[test]
fn a_fig_copy_reports_actual_engine_warnings_and_keeps_the_original_content() {
let temp = Temp::new();
let (mut app, _) = app(Some(temp.path("comments.fig")));
app.run("comment.add", json!({"x": 10, "y": 20, "text": "Synthetic comment"})).unwrap();
let before = app.session.doc().unwrap().clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert!(temp.path("comments.fig").is_file());
assert_eq!(toast(&app), "Exported comments.fig; 1 kind of content was left out");
assert_eq!(app.session.doc().unwrap(), &before);
assert_eq!(app.session.doc().unwrap().comments.len(), 1);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
}
#[test]
fn cancelling_the_platform_dialog_preserves_document_selection_path_and_dirty_state() {
let (mut app, picks) = app(None);
app.session.ds_mut().unwrap().path = Some("working.figcraft".into());
let before = app.session.doc().unwrap().clone();
let selection = app.session.ds().unwrap().selection.clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert_eq!(app.session.doc().unwrap(), &before);
assert_eq!(app.session.ds().unwrap().selection, selection);
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft"));
assert!(app.session.ds().unwrap().is_dirty());
assert!(app.ui.toast.is_none());
}
#[test]
fn unsupported_extensions_are_rejected_without_overwriting_the_chosen_file() {
let temp = Temp::new();
for name in ["wrong.svg", "wrong.png"] {
let path = temp.path(name);
std::fs::write(&path, b"existing document").unwrap();
let (mut app, _) = app(Some(path.clone()));
let before = app.session.doc().unwrap().clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert_eq!(std::fs::read(&path).unwrap(), b"existing document");
assert_eq!(app.session.doc().unwrap(), &before);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), "Use the .figcraft or .fig extension.");
}
}
#[test]
fn adding_the_default_extension_never_overwrites_another_existing_path_without_confirmation() {
let temp = Temp::new();
let path = temp.path("existing.figcraft");
std::fs::write(&path, b"existing document").unwrap();
let (mut app, _) = app(Some(temp.path("existing")));
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert_eq!(std::fs::read(&path).unwrap(), b"existing document");
assert!(!temp.path("existing").exists());
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), "A .figcraft file with this name already exists. Choose its full name to confirm replacement.");
}
#[test]
fn fig_save_results_report_the_actual_name_and_plural_warning_count_without_marking_native_state_saved() {
let (mut app, _) = app(None);
let before = app.session.doc().unwrap().clone();
app.session.ds_mut().unwrap().path = Some("working.figcraft".into());
file_saved(&mut app, &json!({"format": "fig", "path": "written.fig", "warnings": ["synthetic warning", "another synthetic warning"]}));
assert_eq!(toast(&app), "Exported written.fig; 2 kinds of content were left out");
assert_eq!(app.session.doc().unwrap(), &before);
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some("working.figcraft"));
assert!(app.session.ds().unwrap().is_dirty());
}
#[test]
fn save_with_a_native_path_keeps_using_it_without_a_dialog() {
let temp = Temp::new();
let (mut app, picks) = app(None);
let path = temp.path("working.figcraft");
app.run("file.saveAs", json!({"path": path})).unwrap();
app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap();
assert!(app.session.ds().unwrap().is_dirty());
app.save();
assert!(picks.lock().unwrap().is_empty());
assert_eq!(app.session.ds().unwrap().path.as_deref(), path.to_str());
assert!(!app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), "Saved");
}
#[test]
fn saving_legacy_fig_paths_keeps_the_success_toast_and_original_source_bytes() {
for legacy_name in ["legacy.fig", "legacy.FIG"] {
for destination in ["corrected.figcraft", "copy.fig"] {
let temp = Temp::new();
let target = temp.path(destination);
let legacy = temp.path(legacy_name);
let (mut app, picks) = app(Some(target.clone()));
let original = temp.path("original.figcraft");
app.run("file.saveAs", json!({"path": original})).unwrap();
std::fs::rename(&original, &legacy).unwrap();
let legacy_path = legacy.to_string_lossy().into_owned();
app.session.ds_mut().unwrap().path = Some(legacy_path.clone());
app.run("shape.rectangle", json!({"width": 10, "height": 20})).unwrap();
let before = app.session.doc().unwrap().clone();
let legacy_bytes = std::fs::read(&legacy).unwrap();
app.save();
app.poll_dialog(&egui::Context::default());
assert_eq!(*picks.lock().unwrap(), expected_picker());
assert_eq!(std::fs::read(&legacy).unwrap(), legacy_bytes);
assert!(target.is_file());
assert!(app.ui.last_error.is_none());
assert_eq!(app.session.doc().unwrap(), &before);
if destination == "corrected.figcraft" {
assert_eq!(toast(&app), "Saved corrected.figcraft");
assert_eq!(app.session.ds().unwrap().path.as_deref(), target.to_str());
assert!(!app.session.ds().unwrap().is_dirty());
} else {
assert_eq!(toast(&app), "Exported copy.fig");
assert_eq!(app.session.ds().unwrap().path.as_deref(), Some(legacy_path.as_str()));
assert!(app.session.ds().unwrap().is_dirty());
}
}
}
}
#[test]
fn failed_writes_surface_the_engine_error_and_keep_the_document_dirty() {
let temp = Temp::new();
for extension in ["figcraft", "fig"] {
let path = temp.path(&format!("missing/copy.{extension}"));
let (mut app, _) = app(Some(path.clone()));
let before = app.session.doc().unwrap().clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert!(!path.exists());
assert_eq!(app.session.doc().unwrap(), &before);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
assert_eq!(app.ui.last_error.as_deref(), Some(toast(&app)));
assert!(!toast(&app).starts_with("Saved") && !toast(&app).starts_with("Exported"));
}
}
#[test]
fn an_empty_destination_is_rejected_without_writing_or_changing_save_state() {
let (mut app, _) = app(Some(PathBuf::new()));
let before = app.session.doc().unwrap().clone();
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert_eq!(app.session.doc().unwrap(), &before);
assert!(app.session.ds().unwrap().path.is_none());
assert!(app.session.ds().unwrap().is_dirty());
assert_eq!(toast(&app), "Use the .figcraft or .fig extension.");
}
#[test]
fn save_as_with_no_document_never_opens_a_dialog() {
let (picker, picks) = picker(None);
let mut app = FigApp::new(Session::empty(), Box::new(picker));
app.save_as_dialog();
app.poll_dialog(&egui::Context::default());
assert!(picks.lock().unwrap().is_empty());
assert!(app.ui.toast.is_none());
}
+7
View File
@@ -60,6 +60,13 @@ export limit, not a native document nesting limit.
### Export to `.fig` (experimental)
File ▸ Save as offers `.figcraft` and `.fig` as separate file types in the system dialog. A `.fig`
destination writes the same experimental export copy of the document that is shown (`file.save` and
`file.saveAs` with a path ending in `.fig` do the same) and reports what was left out. The file's own
path, its unsaved changes, versions, branches and undo history are not touched, and a shown earlier
version stays shown. Other apps may not open every exported `.fig` file. A plain Save never writes over
a file whose saved path ends in `.fig`; it asks for a destination instead.
`file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental),
`figcraft-cli export <file> -o <out>.fig` (and `convert`) and MCP's `export {format: "fig"}` write
the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format