Default layered flat-image saves to PSD (#2234)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Harrison O'Connor-HooverandBrandon Thomas authored and GitHub committed 2026-10-10 02:23:57 -04:00
1 parent eb20f83654
commit 97f37feaec
2 files changed
+127 -2

No files matched your search

+114
View File
@@ -40,6 +40,120 @@ fn pdn_save_requests_a_native_copy_without_overwriting_the_source() {
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/layers.pcraft"));
}
/// A real flat JPEG import, optionally edited to contain another layer. A filename without a
/// path is the web/download case; desktop opens also retain their source location.
fn jpeg_app(path: Option<&str>, layered: bool) -> (PhotocraftApp, Open, Rc<RefCell<Vec<String>>>) {
let (mut app, open, written) = app();
let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "My image.JPEG", &Default::default()).unwrap().bytes;
let doc = photocraft_io::import("My image.JPEG", &bytes).unwrap().document;
app.session.add_document(doc, path.map(str::to_string));
if layered {
app.run("layer.new.layer", json!({})).unwrap();
}
(app, open, written)
}
#[test]
fn layered_flat_source_save_defaults_to_psd_without_overwriting_source() {
let ctx = egui::Context::default();
for command in ["file.save", "file.saveAs"] {
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
menus::invoke(&mut app, &ctx, command, json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(
matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/My image.psd"),
"{command}: {:?}",
open.borrow().first().map(|(r, _)| r.clone())
);
assert!(written.borrow().is_empty(), "asking where to save must not overwrite the JPEG");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.JPEG"));
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/My image.psd".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(*written.borrow(), ["/pics/My image.psd"]);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/My image.psd"));
assert_eq!(app.session.active().unwrap().doc.layers.len(), 2);
}
}
#[test]
fn layered_filename_only_save_defaults_to_psd() {
let (mut app, open, written) = jpeg_app(None, true);
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "My image.psd"));
answer(&open, Some(FileDialogAnswer::SaveTo("My image.psd".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(*written.borrow(), ["My image.psd"]);
}
#[test]
fn save_dialog_preserves_flat_and_existing_layered_format_defaults() {
let ctx = egui::Context::default();
for (path, layered) in [
("/pics/My image.JPEG", false),
("/pics/My image.PSD", true),
("/pics/My image.psb", true),
("/pics/My image.pcraft", true),
("/pics/My image.TIF", true),
("/pics/My image.tiff", true),
] {
let (mut app, open, written) = jpeg_app(Some(path), layered);
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == path), "{path}");
answer(&open, None);
app.poll_file_dialog(&ctx, None);
assert!(written.borrow().is_empty());
}
}
#[test]
fn transparent_png_keeps_its_format_until_its_lone_layer_becomes_smart() {
let (mut app, open, written) = app();
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).layers[0].surface_mut().unwrap().write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
let bytes = photocraft_io::export(&app.session.active().unwrap().doc, "transparent.PNG", &Default::default()).unwrap().bytes;
let doc = photocraft_io::import("transparent.PNG", &bytes).unwrap().document;
assert_eq!(doc.layers.len(), 1);
assert!(!doc.layers[0].locks.transparency, "transparent PNG imports as an unlocked raster layer");
assert_eq!(doc.layers[0].surface().unwrap().pixel(0, 0)[3], 0.0);
app.session.add_document(doc, Some("/pics/transparent.PNG".into()));
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/transparent.PNG"));
answer(&open, None);
app.poll_file_dialog(&ctx, None);
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1);
assert!(matches!(app.session.active().unwrap().doc.layers[0].content, photocraft_doc::LayerContent::Smart(_)));
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested == "/pics/transparent.psd"));
assert!(written.borrow().is_empty());
answer(&open, None);
app.poll_file_dialog(&ctx, None);
}
#[test]
fn layered_save_respects_explicit_jpeg_choices() {
let ctx = egui::Context::default();
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
answer(&open, Some(FileDialogAnswer::SaveTo("/exports/chosen.JPEG".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(*written.borrow(), ["/exports/chosen.jpeg"]);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/exports/chosen.jpeg"));
// Explicit command paths carry the same choice and bypass the picker.
for command in ["file.save", "file.saveAs"] {
let (mut app, open, written) = jpeg_app(Some("/pics/My image.JPEG"), true);
menus::invoke(&mut app, &ctx, command, json!({"path": "/exports/chosen.jpg"})).unwrap();
assert!(!app.file_dialog_open() && open.borrow().is_empty());
assert_eq!(*written.borrow(), ["/exports/chosen.jpg"]);
}
}
/// The user answers the open dialog, from the dialog's own thread.
fn answer(open: &Open, answer: Option<FileDialogAnswer>) {
let (_, reply) = open.borrow_mut().pop().expect("a dialog is open");
+13 -2
View File
@@ -1002,10 +1002,21 @@ impl PhotocraftApp {
}
let st = self.session.active().ok_or("no document")?;
// PDN imports default to our native format, which preserves Paint.NET's blend modes.
// Other files keep their format when writable, otherwise switch to .psd.
// Other files keep their format only when it can retain their layers. A plain raster
// (including an unlocked transparent PNG) can keep its flat format; editable contents,
// masks and layer appearance settings need a layered format even with just one layer.
let plain_raster = matches!(st.doc.layers.as_slice(), [layer]
if matches!(layer.content, photocraft_doc::LayerContent::Raster(_))
&& layer.visible && layer.opacity >= 1.0 && layer.fill_opacity >= 1.0
&& layer.mask.is_none() && layer.vector_mask.is_none()
&& layer.effects.items.is_empty() && layer.effects.psd_raw.is_none()
&& matches!(layer.blend, photocraft_color::BlendMode::Normal | photocraft_color::BlendMode::PassThrough)
&& photocraft_compose::channel_weights(layer, st.doc.mode).is_none()
&& !photocraft_compose::blend_if_active(layer, st.doc.mode));
let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase());
let writable = ext.is_some_and(|e| {
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb") || photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb" | "tif" | "tiff")
|| (plain_raster && photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write))
});
let suggested = match &st.path {
Some(p) if writable => p.clone(),