mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 05:02:51 +00:00
fix: load every selected Actions file
This commit is contained in:
1 parent
1f984bffb3
commit
9b1be2f526
2 files changed
+135
-2
No files matched your search
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
|
||||
use photocraft_engine::actions_cmds::{self, Action};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::file_dialog::{FileDialogAnswer, FileDialogRequest};
|
||||
use crate::icons;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
@@ -372,7 +373,12 @@ fn source(name: &str, bytes: &[u8]) -> Value {
|
||||
|
||||
/// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…).
|
||||
pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
|
||||
let r = app.run("actions.load", source(name, bytes))?;
|
||||
load_source(app, name, source(name, bytes))
|
||||
}
|
||||
|
||||
/// Import one set and reveal its first action, for both native paths and browser contents.
|
||||
fn load_source(app: &mut PhotocraftApp, name: &str, params: Value) -> Result<Value, String> {
|
||||
let r = app.run("actions.load", params)?;
|
||||
let n = r["actions"].as_array().map_or(0, Vec::len) as u64;
|
||||
let unsupported = r["unsupported"].as_array().map_or(0, Vec::len);
|
||||
let shown = crate::file_open::display_name(name);
|
||||
@@ -407,8 +413,56 @@ pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result<V
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Load every chosen action file in order, retaining failures and unsupported-step warnings.
|
||||
fn load_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes))
|
||||
let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files);
|
||||
app.ask_file(FileDialogRequest::Open { multiple: true, initial_dir, extensions: Some(vec!["atn".into()]) }, |app, answer| {
|
||||
let files: Vec<_> = match answer {
|
||||
FileDialogAnswer::Paths(paths) => paths.into_iter().map(|name| (name, None)).collect(),
|
||||
FileDialogAnswer::Contents(name, bytes) => vec![(name, Some(bytes))],
|
||||
FileDialogAnswer::ContentsMany(files) => files.into_iter().map(|(name, bytes)| (name, Some(bytes))).collect(),
|
||||
_ => return Err("the file dialog gave an unexpected answer".into()),
|
||||
};
|
||||
let count = files.len();
|
||||
if count == 0 {
|
||||
return Err(crate::file_dialog::CANCELLED.into());
|
||||
}
|
||||
let (mut loaded, mut actions, mut unsupported) = (0usize, 0usize, 0usize);
|
||||
let mut errors = Vec::new();
|
||||
let mut last = Value::Null;
|
||||
for (name, bytes) in files {
|
||||
// Native paths go through the engine's size check before reading the file.
|
||||
let params = match bytes {
|
||||
Some(bytes) => source(&name, &bytes),
|
||||
None => json!({"path": name}),
|
||||
};
|
||||
match load_source(app, &name, params) {
|
||||
Ok(r) => {
|
||||
loaded += 1;
|
||||
actions = actions.saturating_add(r["actions"].as_array().map_or(0, Vec::len));
|
||||
unsupported = unsupported.saturating_add(r["unsupported"].as_array().map_or(0, Vec::len));
|
||||
last = r;
|
||||
}
|
||||
Err(e) => errors.push(format!("{}: {e}", crate::file_open::display_name(&name))),
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return match errors.pop() {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(last),
|
||||
};
|
||||
}
|
||||
let template = "Action files loaded: {files}; actions: {actions}; unsupported steps: {unsupported}";
|
||||
let summary =
|
||||
crate::i18n::fmt(tl!(template), &[("files", &loaded.to_string()), ("actions", &actions.to_string()), ("unsupported", &unsupported.to_string())]);
|
||||
if !errors.is_empty() {
|
||||
let template = "{summary}; files failed: {count}. {errors}";
|
||||
return Err(crate::i18n::fmt(tl!(template), &[("summary", &summary), ("count", &errors.len().to_string()), ("errors", &errors.join("; "))]));
|
||||
}
|
||||
app.ui.status = summary;
|
||||
app.ui.status_error = false;
|
||||
Ok(json!({"files": loaded, "actions": actions, "unsupported": unsupported}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Save Actions…: the selected action's set (every action of it), or every action when none
|
||||
|
||||
@@ -1,5 +1,84 @@
|
||||
use super::*;
|
||||
|
||||
/// A small valid Photoshop set, generated without external action files.
|
||||
fn action_set_bytes(set: &str, action: &str) -> Vec<u8> {
|
||||
use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem};
|
||||
use photocraft_psd::descriptor::UnicodeString;
|
||||
photocraft_psd::atn::write(&AtnFile {
|
||||
name: UnicodeString::new_nul(set),
|
||||
actions: vec![AtnAction {
|
||||
name: UnicodeString::new_nul(action),
|
||||
items: vec![AtnItem { event: AtnEvent::Code(*b"Invr"), dictionary: "Invert".into(), ..Default::default() }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Browser multi-selection must not be truncated to the first file.
|
||||
fn bulk_load_imports_every_chosen_action_file() {
|
||||
use crate::file_dialog::{FileDialogAnswer, FileDialogRequest};
|
||||
let files = vec![("first.atn".into(), action_set_bytes("First set", "First")), ("second.atn".into(), action_set_bytes("Second set", "Second"))];
|
||||
let (show, asked) = crate::file_dialog::fake(vec![Some(FileDialogAnswer::ContentsMany(files))]);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() });
|
||||
load_dialog(&mut app).unwrap();
|
||||
app.poll_file_dialog(&egui::Context::default(), None);
|
||||
assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["First", "Second"]);
|
||||
assert!(matches!(asked.borrow().as_slice(), [FileDialogRequest::Open { multiple: true, extensions: Some(ext), .. }] if ext == &["atn"]));
|
||||
assert!(!app.file_dialog_open());
|
||||
assert!(!app.ui.status_error, "{}", app.ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A corrupt middle file cannot hide warnings or prevent a later set replacement.
|
||||
fn bulk_load_keeps_selection_order_and_earlier_warnings_and_errors() {
|
||||
use crate::file_dialog::FileDialogAnswer;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let first = dir.path().join("z-first.atn");
|
||||
let broken = dir.path().join("broken.atn");
|
||||
let last = dir.path().join("a-last.atn");
|
||||
let mut unsupported = photocraft_psd::atn::parse(&action_set_bytes("Same set", "Old")).unwrap();
|
||||
unsupported.actions[0].items[0].event = photocraft_psd::atn::AtnEvent::Code(*b"Xxxx");
|
||||
std::fs::write(&first, photocraft_psd::atn::write(&unsupported).unwrap()).unwrap();
|
||||
std::fs::write(&broken, b"not an action file").unwrap();
|
||||
std::fs::write(&last, action_set_bytes("Same set", "Replacement")).unwrap();
|
||||
let paths = [first, broken, last].map(|p| p.to_string_lossy().into_owned()).to_vec();
|
||||
let mut app = disk_app(vec![Some(FileDialogAnswer::Paths(paths))]);
|
||||
load_dialog(&mut app).unwrap();
|
||||
app.poll_file_dialog(&egui::Context::default(), None);
|
||||
assert_eq!(app.session.actions.list.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Replacement"]);
|
||||
assert_eq!(app.ui.actions.selected, Some(0));
|
||||
assert!(app.ui.status_error, "a later success must not hide an earlier failure");
|
||||
for part in ["files loaded: 2", "unsupported steps: 1", "files failed: 1", "broken.atn"] {
|
||||
assert!(app.ui.status.contains(part), "{part}: {}", app.ui.status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Dismissing the picker is inert; one selected file keeps its existing status message.
|
||||
fn bulk_load_keeps_cancel_empty_and_single_file_behavior() {
|
||||
use crate::file_dialog::FileDialogAnswer;
|
||||
for answer in [None, Some(FileDialogAnswer::Paths(Vec::new())), Some(FileDialogAnswer::ContentsMany(Vec::new()))] {
|
||||
let mut app = disk_app(vec![answer]);
|
||||
app.session.actions.list.push(Action::new("Existing", vec![]));
|
||||
app.ui.actions.selected = Some(0);
|
||||
app.ui.status = "Unchanged".into();
|
||||
load_dialog(&mut app).unwrap();
|
||||
app.poll_file_dialog(&egui::Context::default(), None);
|
||||
assert_eq!(app.session.actions.list[0].name, "Existing");
|
||||
assert_eq!(app.ui.actions.selected, Some(0));
|
||||
assert_eq!(app.ui.status, "Unchanged");
|
||||
}
|
||||
let mut app = disk_app(vec![Some(FileDialogAnswer::Contents("single.atn".into(), action_set_bytes("Single", "One")))]);
|
||||
load_dialog(&mut app).unwrap();
|
||||
app.poll_file_dialog(&egui::Context::default(), None);
|
||||
assert_eq!(app.session.actions.list[0].name, "One");
|
||||
assert!(app.ui.status.contains("single.atn"));
|
||||
assert!(!app.ui.status_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_menu_lists_load_save_and_existing_action_commands() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
Reference in new issue
Block a user