Compare commits

...
Author SHA1 Message Date
Claude 9a1cc948ba brush picker: Import ignores the Open dialog's result, like the other callers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 00:25:45 +00:00
Claude 904a5eeea0 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-09 00:25:38 +00:00
Claude de0f3f5ffb Merge main into issue-673-async-file-dialogs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 20:15:49 +00:00
Claude 34bec8d809 Merge main into issue-673-async-file-dialogs (main's automation test fixed in #1074) 2026-10-08 18:20:57 +00:00
bflatasticandClaude Opus 5.5 2bb3e677f7 File dialogs no longer block the event loop (#673, #574)
Open, Save As, Export As, Place and every other file dialog ran
rfd's synchronous dialog inside winit's event handler. On macOS that
runs AppKit's modal loop there, and an event arriving meanwhile
aborted the app (SIGABRT, panic_cannot_unwind); on Linux the frozen
event loop made compositors offer to kill the app.

The platform shell now shows dialogs through one asynchronous
Services::file_dialog: the request is queued with what to do with the
answer, shown at the end of the frame with the window as its parent
(a sheet on macOS, an owned modal dialog elsewhere) and awaited on a
helper thread; the answer comes back through a channel that wakes the
app, which continues the action on a later frame. One dialog at a
time. The unsaved-changes prompt waits behind Save's dialog and closes
the document only once it saved. The web build uses the same service.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 12:32:55 +07:00
16 changed files with 791 additions and 269 deletions

No files matched your search

+17 -20
View File
@@ -6,7 +6,7 @@ use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
use photocraft_engine::Session;
use photocraft_ui_egui::theme::ThemeKind;
use photocraft_ui_egui::{PhotocraftApp, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogRequest, PhotocraftApp, Services};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
@@ -47,7 +47,7 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let mut app = PhotocraftApp::new(Session::new(), services(inbox.clone()));
listen_pen(&pen_target, app.stylus.feed.clone());
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
if let Some(rs) = cc.wgpu_render_state.clone()
@@ -135,8 +135,7 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
fn services(inbox: Inbox) -> Services {
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
@@ -152,23 +151,21 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
return;
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the file's contents, not a path.
FileDialogRequest::Open { .. } => wasm_bindgen_futures::spawn_local(async move {
let picked = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await;
let answer = match picked {
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
None => None,
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
});
None
})),
// No save dialog on the web: the suggested name becomes the download name.
pick_save: Some(Box::new(|suggested: &str| {
let name = std::path::Path::new(suggested).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| suggested.to_string());
Some(name)
reply.send(answer);
}),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
reply.send(Some(FileDialogAnswer::SaveTo(name)));
}
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
encode_png: Some(Box::new(|w, h, rgba| {
+94 -29
View File
@@ -5,9 +5,11 @@ use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::{Recovered, Services};
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recovered, Services};
use std::cell::RefCell;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
@@ -47,6 +49,66 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, reply: FileDialogReply) {
let mut dialog = rfd::AsyncFileDialog::new();
if let Some(parent) = parent {
dialog = dialog.set_parent(parent);
}
let answer: Pin<Box<dyn Future<Output = Option<FileDialogAnswer>> + Send>> = match request {
FileDialogRequest::Open { multiple } => {
let dialog = dialog.add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]);
if multiple {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
} else {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
}
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || reply.send(block_on(answer))) {
log::error!("couldn't wait for the file dialog: {e}");
}
}
fn path_of(file: &rfd::FileHandle) -> String {
file.path().to_string_lossy().into_owned()
}
/// Runs `future` to completion on this thread, sleeping until it is woken.
fn block_on<T>(future: impl Future<Output = T>) -> T {
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));
let mut cx = std::task::Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
if let std::task::Poll::Ready(value) = future.as_mut().poll(&mut cx) {
return value;
}
// Spurious wake-ups just poll again.
std::thread::park();
}
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
@@ -167,30 +229,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
// A read failure goes back to the app, which reports it like any other open failure.
let bytes = photocraft_format::read_file(&path).map_err(|e| e.to_string());
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_open_paths: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("All Formats", OPEN_EXTS)
.add_filter("PhotoCraft", &["pcraft"])
.pick_files()
.map(|paths| paths.into_iter().map(|path| path.to_string_lossy().into_owned()).collect())
})),
pick_save: Some(Box::new(|suggested: &str| {
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new();
for (name, exts) in save_filters(suggested) {
d = d.add_filter(name, &exts);
}
if let Some(name) = p.file_name() {
d = d.set_file_name(name.to_string_lossy());
}
Some(d.save_file()?.to_string_lossy().to_string())
})),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
automation_read,
automation_write,
@@ -322,22 +361,31 @@ mod tests {
fn every_save_dialog_leads_with_its_own_extension() {
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
let log = asked.clone();
// Record each save dialog's suggested name and cancel it, as the user would.
let services = Services {
pick_save: Some(Box::new(move |s: &str| {
log.borrow_mut().push(s.to_string());
None
file_dialog: Some(Box::new(move |request, _parent, reply| {
if let FileDialogRequest::Save { suggested } = request {
log.borrow_mut().push(suggested);
}
reply.send(None);
})),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
let invoke = |app: &mut PhotocraftApp, id: &str| photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
// Dialogs are shown on the next frame: poll once so each is answered before the next asks.
let invoke = |app: &mut PhotocraftApp, id: &str| {
let r = photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
app.poll_file_dialog(&ctx, None);
r
};
let dialog = |app: &mut PhotocraftApp, id: &str, fields: Value| {
let d = invoke(app, id).unwrap()["dialog"].as_u64().unwrap();
for (k, v) in fields.as_object().unwrap() {
app.ui.dialog_mut(d).unwrap().fields.insert(k.clone(), v.clone());
}
let _ = photocraft_ui_egui::dialogs::confirm(app, d);
app.poll_file_dialog(&ctx, None);
};
new_doc(&mut app, "#ff0000");
// Save As, Save a Copy.
@@ -385,6 +433,23 @@ mod tests {
assert_eq!(save_filters("Untitled")[0].0, "Photoshop", "no extension keeps the default");
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
let mut woken = false;
let ready = std::future::poll_fn(move |cx| {
if woken {
return std::task::Poll::Ready(7);
}
woken = true;
let _ = tx.send(cx.waker().clone());
std::task::Poll::Pending
});
let waker = std::thread::spawn(move || rx.recv().unwrap().wake());
assert_eq!(block_on(ready), 7);
waker.join().unwrap();
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
+17 -16
View File
@@ -159,18 +159,18 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<
}
r
}
"file.import.notes" => {
let (name, bytes) = match app.pick_file_bytes()? {
Ok(picked) => picked,
Err(e) => return Some(Err(e)),
};
let r = photocraft_engine::notes_cmds::import_notes_from(&mut app.session, &name, &bytes).map_err(|e| e.to_string());
if r.is_ok() {
app.ui.analysis.notes = true;
app.sync_views();
}
r
}
"file.import.notes" => match app.active_doc_id() {
Ok(doc) => app.pick_file_bytes(move |app, name, bytes| {
app.refocus(doc)?;
let r = photocraft_engine::notes_cmds::import_notes_from(&mut app.session, &name, &bytes).map_err(|e| e.to_string());
if r.is_ok() {
app.ui.analysis.notes = true;
app.sync_views();
}
r
}),
Err(e) => Err(e),
},
"measurementLog.export" => export_log(app, None),
_ => return None,
})
@@ -193,10 +193,11 @@ fn export_log(app: &mut PhotocraftApp, rows: Option<Vec<u64>>) -> Result<Value,
let p = rows.map_or(json!({}), |r| json!({"rows": r}));
let csv = app.run("measurementLog.export", p)?;
let text = csv["csv"].as_str().unwrap_or_default().to_string();
let path = app.services.pick_save.as_mut().and_then(|f| f("Measurements.csv")).ok_or("cancelled")?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, text.as_bytes())?;
Ok(json!({"path": path, "rows": csv["rows"]}))
app.pick_save("Measurements.csv", move |app, path| {
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, text.as_bytes())?;
Ok(json!({"path": path, "rows": csv["rows"]}))
})
}
// ------------------------------------------------------------------ tools
+3 -1
View File
@@ -85,7 +85,9 @@ pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, picks: Vec<Pick>) {
app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, text: String::new() });
}
}
Pick::Import => app.open_dialog_file(),
Pick::Import => {
let _ = app.open_dialog_file();
}
}
}
}
+1 -1
View File
@@ -477,7 +477,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
let _ = app.open_dialog_file();
}
});
});
+1 -1
View File
@@ -1651,7 +1651,7 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
app.ui.open_dialog(crate::state::DialogKind::NewDocument, fields);
}
if crate::widgets::secondary_button(ui, &open_label, 190.0).clicked() {
app.open_dialog_file();
let _ = app.open_dialog_file();
}
});
ui.add_space(22.0);
+89 -20
View File
@@ -117,23 +117,46 @@ fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
}
/// Saves `doc` in place; false when it is gone, the save failed or its file dialog was cancelled.
/// Saves `doc`; true once it is saved. A document without a file to write back to asks where in
/// the save dialog: the prompt then hides until it is answered, and moves on only if it saved.
fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool {
let Some(i) = index_of(app, doc) else { return false };
app.session.set_active(i);
match crate::menus::invoke_unguarded(app, ctx, "file.save", json!({})) {
let saved = crate::menus::invoke_unguarded(app, ctx, "file.save", json!({}));
if saved.is_ok() && app.file_dialog_open() {
let ctx = ctx.clone();
app.after_file_dialog(move |app, saved| {
let saved = saved.map_err(couldnt_save)?;
// Still the prompt that asked (quitting may have replaced it meanwhile).
if app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) {
advance(app, &ctx);
}
Ok(saved)
});
return false;
}
match saved {
Ok(_) => true,
// Backing out of the file dialog is the user's choice, not an error.
Err(e) if e == "cancelled" => false,
Err(e) if e == crate::file_dialog::CANCELLED => false,
Err(e) => {
app.ui.status = format!("Couldn't save: {e}");
app.ui.status = couldnt_save(e);
app.ui.status_error = true;
false
}
}
}
/// A save failure as reported ("cancelled" stays as it is: it isn't reported).
fn couldnt_save(e: String) -> String {
if e == crate::file_dialog::CANCELLED { e } else { format!("Couldn't save: {e}") }
}
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Hidden while Save's file dialog is up; it's back if that is cancelled.
if app.file_dialog_open() {
return;
}
let Some(p) = &app.discard else { return };
let Some(&doc) = p.docs.first() else { return };
let (exits, reverts) = (p.id == EXIT, p.id == "file.revert");
@@ -337,17 +360,29 @@ mod tests {
type Prompted = egui_kittest::Harness<'static, PhotocraftApp>;
/// The unsaved-changes prompt for `command` over `app`'s dirty documents, as `os` draws it (with
/// the file dialogs shown and answered at the end of each frame, as the app does).
fn prompt_for(app: PhotocraftApp, command: &str, os: egui::os::OperatingSystem) -> Prompted {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(
|ui, app| {
show(app, ui.ctx());
app.poll_file_dialog(ui.ctx(), None);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.set_os(os);
assert!(intercept(h.state_mut(), command, &Value::Null));
h.run_steps(2);
h
}
/// The unsaved-changes prompt for Close All over two dirty documents, as `os` draws it.
fn prompt_on(os: egui::os::OperatingSystem) -> Prompted {
let mut app = app_with_docs(2);
make_dirty(&mut app, 0);
make_dirty(&mut app, 1);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.ctx.set_os(os);
assert!(intercept(h.state_mut(), "file.closeAll", &Value::Null));
h.run_steps(2);
h
prompt_for(app, "file.closeAll", os)
}
/// The buttons named `labels`, sorted by where they are drawn, left to right.
@@ -414,31 +449,65 @@ mod tests {
#[test]
fn enter_takes_the_default_answer_or_the_focused_button() {
use std::{cell::Cell, rc::Rc};
let mut h = prompt_on(egui::os::OperatingSystem::Windows);
let asked = Rc::new(Cell::new(false));
let seen = asked.clone();
// Backing out of the save dialog keeps the prompt.
h.state_mut().services.pick_save = Some(Box::new(move |_| {
seen.set(true);
None
}));
let (show, asked) = crate::file_dialog::fake(vec![None]);
h.state_mut().services.file_dialog = Some(show);
h.key_press(Key::Enter);
h.run_steps(2);
assert!(asked.get(), "Enter answered Yes, which saves");
assert_eq!(asked.borrow().len(), 1, "Enter answered Yes, which saves");
assert_eq!(docs_left(&h), Some(2));
// Tab to No: Enter now presses it.
for _ in 0..2 {
h.key_press(Key::Tab);
h.run_steps(2);
}
asked.set(false);
h.key_press(Key::Enter);
h.run_steps(2);
assert!(!asked.get());
assert_eq!(asked.borrow().len(), 1, "no second dialog");
assert_eq!(docs_left(&h), Some(1), "Enter pressed the focused No");
}
#[test]
fn saving_from_the_prompt_waits_for_the_save_dialog_then_closes() {
use crate::{FileDialogAnswer, FileDialogReply};
use egui_kittest::kittest::Queryable;
use std::{cell::RefCell, rc::Rc};
let written: Rc<RefCell<Vec<String>>> = Rc::default();
let (w, waiting): (_, Rc<RefCell<Option<FileDialogReply>>>) = (written.clone(), Rc::default());
let held = waiting.clone();
let mut app = app_with_docs(1);
make_dirty(&mut app, 0);
app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new()))));
app.services.write = Some(Box::new(move |p: &str, _: &[u8]| {
w.borrow_mut().push(p.to_string());
Ok(())
}));
// A dialog the user takes their time with.
app.services.file_dialog = Some(Box::new(move |_, _, reply| *held.borrow_mut() = Some(reply)));
let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows);
let answer = |h: &mut Prompted, a: Option<FileDialogAnswer>| {
waiting.borrow_mut().take().expect("the save dialog is open").send(a);
h.run_steps(2);
};
// Cancelling the save dialog brings the prompt back.
h.key_press(Key::Y);
h.run_steps(3);
assert!(h.state().file_dialog_open());
assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the dialog");
answer(&mut h, None);
assert_eq!(docs_left(&h), Some(1));
assert!(h.query_by_label("(Y)es").is_some());
// Saving closes the document.
h.key_press(Key::Y);
h.run_steps(3);
assert_eq!(h.state().session.documents().len(), 1, "still open while the dialog is");
answer(&mut h, Some(FileDialogAnswer::SaveTo("/pics/kept.psd".into())));
assert_eq!(*written.borrow(), ["/pics/kept.psd"]);
assert!(h.state().discard.is_none());
assert!(h.state().session.documents().is_empty());
}
/// One frame with the window's close button pressed; whether the guard cancelled the close.
fn press_window_close(app: &mut PhotocraftApp) -> bool {
let mut info = egui::ViewportInfo::default();
+29 -21
View File
@@ -239,16 +239,18 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a).to_string();
let ext = s_fmt(f);
let suggested = format!("{stem}.{ext}");
let path = app.services.pick_save.as_mut().and_then(|p| p(&suggested)).ok_or("cancelled")?;
let out = export_document(&doc, f, None)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(&out, &path, &settings(f))?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
app.ui.status = format!("Exported {path} ({})", crate::sizing::human_bytes(bytes.len() as f64));
app.ui.status_error = false;
crate::notices::io_warnings(app, &format!("Exported {}", crate::file_open::display_name(&path)), &warnings);
Ok(json!({"path": path, "bytes": bytes.len(), "warnings": warnings}))
let (f, settings) = (f.clone(), settings(f));
app.pick_save(&suggested, move |app, path| {
let out = export_document(&doc, &f, None)?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(&out, &path, &settings)?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
app.ui.status = format!("Exported {path} ({})", crate::sizing::human_bytes(bytes.len() as f64));
app.ui.status_error = false;
crate::notices::io_warnings(app, &format!("Exported {}", crate::file_open::display_name(&path)), &warnings);
Ok(json!({"path": path, "bytes": bytes.len(), "warnings": warnings}))
})
}
/// File › Export › Quick Export as PNG: the format, quality, metadata, colour space and location
@@ -260,17 +262,16 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result<Value, String> {
let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into());
let same = serde_json::to_value(prefs.quick_export_location).ok().is_some_and(|v| v == "sameFolder");
let saved = app.session.active().is_some_and(|d| d.path.is_some());
let p = if same && saved {
json!({})
} else {
let st = app.session.active().ok_or("no document")?;
let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a).to_string();
let path = app.services.pick_save.as_mut().and_then(|p| p(&format!("{stem}.{fmt}"))).ok_or("cancelled")?;
json!({"path": path})
};
let r = app.run("file.export.quickExport", p)?;
app.ui.status = format!("Exported {}", r["path"].as_str().unwrap_or_default());
return Ok(r);
if same && saved {
return quick_export(app, json!({}));
}
let st = app.session.active().ok_or("no document")?;
let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a);
let (suggested, doc) = (format!("{stem}.{fmt}"), st.doc.id);
return app.pick_save(&suggested, move |app, path| {
app.refocus(doc)?;
quick_export(app, json!({"path": path}))
});
}
let mut f = Map::new();
f.insert("format".into(), json!("png"));
@@ -279,6 +280,13 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result<Value, String> {
confirm(app, &f)
}
/// Runs Quick Export with `p` (an explicit `path`, or none for the document's folder).
fn quick_export(app: &mut PhotocraftApp, p: Value) -> Result<Value, String> {
let r = app.run("file.export.quickExport", p)?;
app.ui.status = format!("Exported {}", r["path"].as_str().unwrap_or_default());
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
+224
View File
@@ -0,0 +1,224 @@
//! File dialogs that never block the event loop (#673, #574).
//!
//! A command that needs a file asks with [`PhotocraftApp::ask_file`] (or one of its wrappers) and
//! hands over what to do with the answer. [`PhotocraftApp::poll_file_dialog`], every frame, shows
//! the queued dialog through [`Services::file_dialog`](crate::Services::file_dialog) with the
//! window as its parent (so it is modal to the window, as Photoshop's are) and continues the
//! action once the platform shell answers through the [`FileDialogReply`], from any thread. The
//! window keeps drawing and answering the compositor meanwhile. A dialog run to completion inside
//! the frame froze the window (Linux compositors offered to kill it, #574) and, on macOS, ran
//! AppKit's modal loop inside winit's event handler, which aborts the app when another event
//! arrives (#673).
//!
//! One dialog at a time: asking while one is open is refused.
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use photocraft_doc::DocId;
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::file_open::display_name;
/// What a file dialog asks the user for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogRequest {
/// Files to read: several for File › Open, one for a command that reads a file (Place,
/// scripts, notes, presets).
Open { multiple: bool },
/// Where to write, starting from `suggested` (a file name, or the document's own path).
Save { suggested: String },
}
/// The user's choice (a cancelled dialog answers `None`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileDialogAnswer {
/// The chosen files on disk (desktop); the app reads them.
Paths(Vec<String>),
/// A chosen file's name and contents (the web, where a page gets contents rather than paths).
Contents(String, Vec<u8>),
/// The path to save to (desktop), or the download's name (web).
SaveTo(String),
}
/// Hands a dialog's answer to the app and wakes it, from any thread. Dropping it unanswered
/// (a dialog that failed to show) counts as Cancel.
pub struct FileDialogReply {
tx: Sender<Option<FileDialogAnswer>>,
ctx: egui::Context,
}
impl FileDialogReply {
pub fn send(self, answer: Option<FileDialogAnswer>) {
// Nothing is waiting any more when the app has quit meanwhile.
let _ = self.tx.send(answer);
self.ctx.request_repaint();
}
}
/// Show a file dialog and return at once, answering through the reply when the user is done.
/// The window (`None` in tests) is the dialog's parent.
pub type FileDialogFn = Box<dyn FnMut(FileDialogRequest, Option<&eframe::Frame>, FileDialogReply)>;
/// What to do with the answer (`None`: cancelled).
type Then = Box<dyn FnOnce(&mut PhotocraftApp, Option<FileDialogAnswer>) -> Result<Value, String>>;
/// The file dialog in progress.
pub(crate) struct Pending {
/// Not shown yet: it is shown at the end of the frame, which has the window to parent it to.
request: Option<FileDialogRequest>,
answer: Option<Receiver<Option<FileDialogAnswer>>>,
then: Then,
}
/// The error of a cancelled dialog: not reported, since backing out is the user's choice.
pub(crate) const CANCELLED: &str = "cancelled";
const UNEXPECTED: &str = "the file dialog gave an unexpected answer";
impl PhotocraftApp {
/// Whether a file dialog is open (or about to be shown).
pub fn file_dialog_open(&self) -> bool {
self.file_dialog.is_some()
}
/// Show a file dialog; `then` continues with the user's choice on a later frame (see the
/// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a
/// dialog service ("cancelled", as before there were async dialogs) or while one is open.
pub(crate) fn ask_file(
&mut self,
request: FileDialogRequest,
then: impl FnOnce(&mut Self, FileDialogAnswer) -> Result<Value, String> + 'static,
) -> Result<Value, String> {
if self.services.file_dialog.is_none() {
return Err(CANCELLED.into());
}
if self.file_dialog.is_some() {
return Err("a file dialog is already open".into());
}
let kind = match request {
FileDialogRequest::Open { .. } => "open",
FileDialogRequest::Save { .. } => "save",
};
let then: Then = Box::new(move |app, answer| answer.map_or_else(|| Err(CANCELLED.into()), |a| then(app, a)));
self.file_dialog = Some(Pending { request: Some(request), answer: None, then });
Ok(json!({ "fileDialog": kind }))
}
/// Ask where to save: `then` gets the chosen path.
pub(crate) fn pick_save(&mut self, suggested: &str, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string() }, move |app, answer| match answer {
FileDialogAnswer::SaveTo(path) => then(app, path),
_ => Err(UNEXPECTED.into()),
})
}
/// Ask for a file a command reads (a script, notes, a placed image, presets): `then` gets its
/// name (the full path on the desktop) and bytes. A file that can't be read fails with
/// "<file name>: <why>".
pub(crate) fn pick_file_bytes(&mut self, then: impl FnOnce(&mut Self, String, Vec<u8>) -> Result<Value, String> + 'static) -> Result<Value, String> {
self.ask_file(FileDialogRequest::Open { multiple: false }, move |app, answer| {
let (name, bytes) = read_picked(answer)?;
then(app, name, bytes)
})
}
/// File › Open: opens every chosen file, reporting each failure (see [`Self::open_paths`]).
pub fn open_dialog_file(&mut self) -> Result<Value, String> {
self.ask_file(FileDialogRequest::Open { multiple: true }, |app, answer| {
match answer {
FileDialogAnswer::Paths(paths) => {
app.open_paths(&paths);
}
FileDialogAnswer::Contents(name, bytes) => {
if let Err(e) = app.open_bytes(&name, &bytes) {
app.open_failed(&name, &e);
}
}
FileDialogAnswer::SaveTo(_) => return Err(UNEXPECTED.into()),
}
Ok(Value::Null)
})
}
/// Run `next` on the result of the open dialog's action once it is answered; its result
/// replaces the action's. False when no dialog is open.
pub(crate) fn after_file_dialog(&mut self, next: impl FnOnce(&mut Self, Result<Value, String>) -> Result<Value, String> + 'static) -> bool {
let Some(p) = self.file_dialog.as_mut() else { return false };
let then = std::mem::replace(&mut p.then, Box::new(|_, _| Ok(Value::Null)));
p.then = Box::new(move |app, answer| {
let r = then(app, answer);
next(app, r)
});
true
}
/// The document `id` becomes active again: an answer applies to the document it was asked
/// for, even when another one was activated while the dialog was open. `Err` once it's closed.
pub(crate) fn refocus(&mut self, id: DocId) -> Result<(), String> {
let i = self.session.documents().iter().position(|d| d.doc.id == id).ok_or("no document")?;
self.session.set_active(i);
Ok(())
}
/// The active document's id, for [`Self::refocus`].
pub(crate) fn active_doc_id(&self) -> Result<DocId, String> {
Ok(self.session.active().ok_or("no document")?.doc.id)
}
/// Called every frame: shows a queued dialog with `parent` (the window) as its parent, and
/// continues the action that asked once the answer is in. Its errors go to the status bar.
pub fn poll_file_dialog(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) {
let Some(p) = self.file_dialog.as_mut() else { return };
if let Some(request) = p.request.take() {
let (tx, rx) = mpsc::channel();
p.answer = Some(rx);
if let Some(show) = self.services.file_dialog.as_mut() {
show(request, parent, FileDialogReply { tx, ctx: ctx.clone() });
}
}
let answer = match p.answer.as_ref().map(Receiver::try_recv) {
Some(Err(TryRecvError::Empty)) => return,
Some(Ok(answer)) => answer,
// The reply was dropped unanswered: the dialog couldn't be shown.
Some(Err(TryRecvError::Disconnected)) | None => None,
};
let Some(Pending { then, .. }) = self.file_dialog.take() else { return };
if let Err(e) = then(self, answer)
&& e != CANCELLED
{
self.ui.status = e;
self.ui.status_error = true;
}
}
}
/// The name and bytes of the single file an open dialog answered with.
fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec<u8>), String> {
match answer {
FileDialogAnswer::Contents(name, bytes) => Ok((name, bytes)),
FileDialogAnswer::Paths(paths) => {
let path = paths.into_iter().next().ok_or(CANCELLED)?;
let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?;
Ok((path, bytes))
}
FileDialogAnswer::SaveTo(_) => Err(UNEXPECTED.into()),
}
}
/// A fake dialog service for tests: answers each request with the next of `answers` (`None`:
/// Cancel), recording the requests.
#[cfg(test)]
pub(crate) fn fake(answers: Vec<Option<FileDialogAnswer>>) -> (FileDialogFn, std::rc::Rc<std::cell::RefCell<Vec<FileDialogRequest>>>) {
let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let log = asked.clone();
let mut answers = answers.into_iter();
let show: FileDialogFn = Box::new(move |request, _parent, reply| {
log.borrow_mut().push(request);
reply.send(answers.next().flatten());
});
(show, asked)
}
#[cfg(test)]
#[path = "file_dialog/tests.rs"]
mod tests;
+129
View File
@@ -0,0 +1,129 @@
use super::*;
use crate::{Services, menus};
use std::cell::RefCell;
use std::rc::Rc;
/// The requests the fake dialog service was given, with their replies: dialogs the user is still in.
type Open = Rc<RefCell<Vec<(FileDialogRequest, FileDialogReply)>>>;
/// An app with an untitled document, whose file dialogs stay open until the test answers them, and
/// whose writer records the paths it wrote.
fn app() -> (PhotocraftApp, Open, Rc<RefCell<Vec<String>>>) {
let (open, written): (Open, Rc<RefCell<Vec<String>>>) = Default::default();
let (held, w) = (open.clone(), written.clone());
let services = Services {
export: Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))),
write: Some(Box::new(move |path: &str, _: &[u8]| {
w.borrow_mut().push(path.to_string());
Ok(())
})),
file_dialog: Some(Box::new(move |request, _parent, reply| held.borrow_mut().push((request, reply)))),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
(app, open, written)
}
/// 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");
std::thread::spawn(move || reply.send(answer)).join().unwrap();
}
#[test]
fn the_app_keeps_running_while_a_dialog_is_open() {
let (mut app, open, written) = app();
let ctx = egui::Context::default();
assert_eq!(menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(), json!({"fileDialog": "save"}));
assert!(open.borrow().is_empty(), "shown at the end of the frame, which has the window to parent it to");
app.poll_file_dialog(&ctx, None);
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Save { suggested }, _)] if suggested.ends_with(".psd")));
// Frames go on while the user is in the dialog: nothing is written, commands still run, and
// a second dialog is refused.
for _ in 0..3 {
app.poll_file_dialog(&ctx, None);
}
assert!(written.borrow().is_empty());
assert!(app.file_dialog_open());
app.run("layer.new.layer", json!({})).unwrap();
assert_eq!(menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap_err(), "a file dialog is already open");
app.poll_file_dialog(&ctx, None);
assert_eq!(open.borrow().len(), 1);
// The answer wakes the app, and the save goes on from where it asked.
for _ in 0..4 {
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
}
assert!(!ctx.has_requested_repaint(), "idle");
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/a.psd".into())));
assert!(ctx.has_requested_repaint());
app.poll_file_dialog(&ctx, None);
assert_eq!(*written.borrow(), ["/pics/a.psd"]);
let st = app.session.active().unwrap();
assert_eq!(st.path.as_deref(), Some("/pics/a.psd"));
assert!(!st.is_dirty());
assert!(!app.file_dialog_open());
}
#[test]
fn the_answer_goes_to_the_document_it_was_asked_for() {
let (mut app, open, written) = app();
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
app.session.set_active(0);
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
app.session.set_active(1);
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/first.psd".into())));
app.poll_file_dialog(&ctx, None);
let paths: Vec<_> = app.session.documents().iter().map(|d| d.path.clone()).collect();
assert_eq!(paths, [Some("/pics/first.psd".to_string()), None]);
assert_eq!(app.session.active_index(), Some(0));
// Once that document is closed, the answer has nothing to save.
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
app.run("file.close", json!({"document": 0})).unwrap();
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/again.psd".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(*written.borrow(), ["/pics/first.psd"]);
assert!(app.ui.status_error && app.session.documents()[0].path.is_none());
}
#[test]
fn cancel_and_a_dialog_that_could_not_show_end_quietly() {
let (mut app, open, written) = app();
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
answer(&open, None);
app.poll_file_dialog(&ctx, None);
assert!(!app.file_dialog_open() && !app.ui.status_error);
// A reply dropped unanswered (the dialog failed to show) is a Cancel, not a dialog left open forever.
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
open.borrow_mut().clear();
app.poll_file_dialog(&ctx, None);
assert!(!app.file_dialog_open() && !app.ui.status_error);
assert!(written.borrow().is_empty());
// Without a dialog service, asking is a Cancel straight away.
app.services.file_dialog = None;
assert_eq!(app.open_dialog_file().unwrap_err(), CANCELLED);
assert!(!app.file_dialog_open());
}
#[test]
fn an_answer_of_the_wrong_kind_is_an_error_not_a_crash() {
let (mut app, open, written) = app();
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
answer(&open, Some(FileDialogAnswer::Paths(vec!["/pics/a.psd".into()])));
app.poll_file_dialog(&ctx, None);
assert!(app.ui.status_error && app.ui.status == UNEXPECTED);
assert!(written.borrow().is_empty());
app.open_dialog_file().unwrap();
app.poll_file_dialog(&ctx, None);
answer(&open, Some(FileDialogAnswer::SaveTo("/pics/a.psd".into())));
app.poll_file_dialog(&ctx, None);
assert_eq!(app.session.documents().len(), 1);
}
+94 -63
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::file_dialog::{self, FileDialogAnswer, FileDialogRequest};
use crate::{Services, menus, notices};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::Document;
@@ -12,11 +13,10 @@ type Written = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
/// An app whose importer names the document after the file (like `photocraft-io`) and reports a
/// warning for names containing "warn", and fails for bytes "bad"; the exporter warns for ".png"
/// and the writer records what it wrote.
fn app_with(pick_open: Option<(String, Vec<u8>)>, pick_save: Option<String>) -> (PhotocraftApp, Written) {
/// and the writer records what it wrote. Its file dialogs answer with `answers`, in order.
fn app_with(answers: Vec<Option<FileDialogAnswer>>) -> (PhotocraftApp, Written) {
let written: Written = Rc::default();
let w = written.clone();
let mut pick_open = pick_open;
let services = Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| {
if bytes == b"bad" {
@@ -29,8 +29,7 @@ fn app_with(pick_open: Option<(String, Vec<u8>)>, pick_save: Option<String>) ->
let warnings = if path.ends_with(".png") { vec!["Layers were flattened".to_string()] } else { Vec::new() };
Ok((b"out".to_vec(), warnings))
})),
pick_open: Some(Box::new(move || pick_open.take().map(|(name, bytes)| (name, Ok(bytes))))),
pick_save: Some(Box::new(move |_s: &str| pick_save.clone())),
file_dialog: Some(file_dialog::fake(answers).0),
write: Some(Box::new(move |p: &str, b: &[u8]| {
w.borrow_mut().push((p.to_string(), b.to_vec()));
Ok(())
@@ -40,6 +39,26 @@ fn app_with(pick_open: Option<(String, Vec<u8>)>, pick_save: Option<String>) ->
(PhotocraftApp::new(photocraft_engine::Session::new(), services), written)
}
/// The end of a frame: the dialog asked for is shown, and its answer acted on.
fn answer(app: &mut PhotocraftApp) {
app.poll_file_dialog(&egui::Context::default(), None);
}
/// Files named `names` (holding `bytes`) in a fresh directory for `tag`: (directory, paths).
fn files(tag: &str, names: &[&str], bytes: &[u8]) -> (std::path::PathBuf, Vec<String>) {
let dir = std::env::temp_dir().join(format!("photocraft-file-open-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let paths = names
.iter()
.map(|name| {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
})
.collect();
(dir, paths)
}
#[test]
fn display_name_is_the_file_name() {
assert_eq!(display_name("/a/b/photo.psd"), "photo.psd");
@@ -50,7 +69,7 @@ fn display_name_is_the_file_name() {
#[test]
fn open_file_sets_name_path_and_recent() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let w = app.open_file("/pics/cat.psd", b"x").unwrap();
assert!(w.is_empty());
let st = app.session.active().unwrap();
@@ -64,33 +83,30 @@ fn open_file_sets_name_path_and_recent() {
#[test]
fn file_open_dialog_sets_path_so_save_writes_in_place() {
// The dialog returns a full path: the document is named after the file, not the path.
let (mut app, written) = app_with(Some(("/pics/cat.psd".into(), b"x".to_vec())), None);
let (dir, paths) = files("in-place", &["cat.psd"], b"x");
let (mut app, written) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]);
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap();
assert_eq!(menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap(), json!({"fileDialog": "open"}));
answer(&mut app);
let st = app.session.active().unwrap();
assert_eq!(st.doc.name, "cat.psd");
assert_eq!(st.path.as_deref(), Some("/pics/cat.psd"));
assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/pics/cat.psd"));
// File › Save goes straight back to the file (pick_save would return None = cancelled).
assert_eq!(st.path.as_ref(), paths.first());
assert_eq!(app.ui.recent_files.first(), paths.first());
// File › Save goes straight back to the file, without a dialog.
let r = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();
assert_eq!(r["path"], "/pics/cat.psd");
assert_eq!(written.borrow().last().map(|(p, _)| p.clone()).as_deref(), Some("/pics/cat.psd"));
assert_eq!(r["path"], paths[0]);
assert!(!app.file_dialog_open());
assert_eq!(written.borrow().last().map(|(p, _)| p), paths.first());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_open_dialog_opens_every_selected_path() {
let dir = std::env::temp_dir().join(format!("photocraft-issue-595-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let paths = ["one.psd", "two.png"].map(|name| {
let path = dir.join(name);
std::fs::write(&path, b"x").unwrap();
path.to_string_lossy().into_owned()
});
let (mut app, _) = app_with(None, None);
let selected = paths.to_vec();
app.services.pick_open_paths = Some(Box::new(move || Some(selected.clone())));
let (dir, paths) = files("issue-595", &["one.psd", "two.png"], b"x");
let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths.clone()))]);
menus::invoke(&mut app, &egui::Context::default(), "file.open", json!({})).unwrap();
answer(&mut app);
assert_eq!(app.session.documents().len(), 2);
assert_eq!(
@@ -102,18 +118,29 @@ fn file_open_dialog_opens_every_selected_path() {
}
#[test]
fn cancelling_multi_file_open_does_not_fall_back_to_single_file_picker() {
let (mut app, _) = app_with(Some(("/pics/unexpected.psd".into(), b"x".to_vec())), None);
app.services.pick_open_paths = Some(Box::new(|| None));
fn cancelling_file_open_opens_nothing() {
let (mut app, _) = app_with(vec![None]);
menus::invoke(&mut app, &egui::Context::default(), "file.open", json!({})).unwrap();
answer(&mut app);
assert!(app.session.documents().is_empty());
assert!(!app.ui.status_error, "Cancel is not an error");
assert!(!app.file_dialog_open());
}
#[test]
fn the_web_hands_over_contents_rather_than_paths() {
let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Contents("cat.png".into(), b"x".to_vec()))]);
menus::invoke(&mut app, &egui::Context::default(), "file.open", json!({})).unwrap();
answer(&mut app);
let st = app.session.active().unwrap();
assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("cat.png", None));
}
#[test]
fn pcraft_documents_save_in_place_but_flat_files_ask() {
let (mut app, written) = app_with(None, None);
let (mut app, written) = app_with(vec![None]);
let ctx = egui::Context::default();
app.open_file("/pics/work.pcraft", b"x").unwrap();
let r = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();
@@ -121,13 +148,15 @@ fn pcraft_documents_save_in_place_but_flat_files_ask() {
assert_eq!(written.borrow().len(), 1);
// A PNG goes through Save As (cancelled here), not silently flattened over the original.
app.open_file("/pics/flat.png", b"x").unwrap();
assert_eq!(menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap_err(), "cancelled");
assert_eq!(menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(), json!({"fileDialog": "save"}));
answer(&mut app);
assert_eq!(written.borrow().len(), 1);
assert!(!app.ui.status_error);
}
#[test]
fn templates_open_as_new_untitled_documents() {
let (mut app, written) = app_with(None, None);
let (mut app, written) = app_with(Vec::new());
let ctx = egui::Context::default();
app.open_file("/pics/card.PSDT", b"x").unwrap();
app.open_file("/pics/card.psdt", b"x").unwrap();
@@ -136,25 +165,19 @@ fn templates_open_as_new_untitled_documents() {
assert!(app.session.documents().iter().all(|d| d.path.is_none()));
assert_eq!(app.ui.recent_files.first().map(String::as_str), Some("/pics/card.psdt"));
// File › Save asks for a new name (cancelled here) rather than suggesting the template.
let suggested: Rc<RefCell<Vec<String>>> = Rc::default();
let rec = suggested.clone();
app.services.pick_save = Some(Box::new(move |s: &str| {
rec.borrow_mut().push(s.to_string());
None
}));
assert_eq!(menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap_err(), "cancelled");
assert_eq!(*suggested.borrow(), ["Untitled-2.psd"]);
let (show, asked) = file_dialog::fake(vec![None]);
app.services.file_dialog = Some(show);
menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();
answer(&mut app);
assert_eq!(*asked.borrow(), [FileDialogRequest::Save { suggested: "Untitled-2.psd".into() }]);
assert!(written.borrow().is_empty());
}
#[test]
fn import_warnings_reach_status_notice_and_control_response() {
let dir = std::env::temp_dir().join(format!("photocraft-open-warn-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("warn.psd");
std::fs::write(&path, b"x").unwrap();
let p = path.to_string_lossy().to_string();
let (mut app, _) = app_with(None, None);
let (dir, paths) = files("warn", &["warn.psd"], b"x");
let p = paths[0].clone();
let (mut app, _) = app_with(Vec::new());
let ctx = egui::Context::default();
let r = menus::invoke(&mut app, &ctx, "file.open", json!({ "path": p })).unwrap();
assert_eq!(r["warnings"][0], "Adjustment layer \"Curves 1\" was flattened");
@@ -170,7 +193,7 @@ fn import_warnings_reach_status_notice_and_control_response() {
#[test]
fn export_warnings_reach_status_notice_and_control_response() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let ctx = egui::Context::default();
app.open_file("/pics/cat.psd", b"x").unwrap();
let r = menus::invoke(&mut app, &ctx, "file.saveAs", json!({ "path": "/pics/cat.png" })).unwrap();
@@ -186,36 +209,44 @@ fn export_warnings_reach_status_notice_and_control_response() {
#[test]
fn open_failures_are_errors_and_leave_no_document() {
let (mut app, _) = app_with(Some(("/pics/broken.psd".into(), b"bad".to_vec())), None);
let (dir, paths) = files("broken", &["broken.psd"], b"bad");
let (mut app, _) = app_with(vec![Some(FileDialogAnswer::Paths(paths))]);
let ctx = egui::Context::default();
menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap();
answer(&mut app);
assert!(app.session.documents().is_empty());
assert!(app.ui.recent_files.is_empty());
assert!(app.ui.status_error);
assert!(app.ui.status.starts_with("Couldn't open broken.psd"), "{}", app.ui.status);
assert!(app.ui.notices.last().is_some_and(|n| n.error));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_picked_file_that_cannot_be_read_is_reported_like_any_open_failure() {
let (mut app, _) = app_with(None, None);
let mut picked = Some(("C:/photos/big.psb".to_string(), Err("The parameter is incorrect. (os error 87)".to_string())));
app.services.pick_open = Some(Box::new(move || picked.take()));
app.open_dialog_file();
let missing = std::env::temp_dir().join("photocraft-missing-dir").join("big.psb").to_string_lossy().into_owned();
let picked = || Some(FileDialogAnswer::Paths(vec![missing.clone()]));
let (mut app, _) = app_with(vec![picked(), picked(), None]);
app.open_dialog_file().unwrap();
answer(&mut app);
assert!(app.session.documents().is_empty());
assert!(app.ui.status_error);
let notice = app.ui.notices.last().unwrap();
assert!(notice.error && notice.title.contains("Couldn't open big.psb: The parameter is incorrect"), "{}", notice.title);
// Commands that read a picked file (scripts, notes, Place) get the same message as an error.
app.services.pick_open = Some(Box::new(|| Some(("C:/photos/big.psb".to_string(), Err("denied".to_string())))));
assert_eq!(app.pick_file_bytes(), Some(Err("big.psb: denied".to_string())));
app.services.pick_open = Some(Box::new(|| None));
assert_eq!(app.pick_file_bytes(), None, "cancelled");
assert!(notice.error && notice.title.starts_with("Couldn't open big.psb: "), "{}", notice.title);
// Commands that read a picked file (scripts, notes, Place) fail with the file's name.
app.ui.status_error = false;
app.pick_file_bytes(|_, _, _| Err("read".into())).unwrap();
answer(&mut app);
assert!(app.ui.status_error && app.ui.status.starts_with("big.psb: "), "{}", app.ui.status);
app.ui.status_error = false;
app.pick_file_bytes(|_, _, _| Err("read".into())).unwrap();
answer(&mut app);
assert!(!app.ui.status_error, "cancelled");
}
#[test]
fn open_paths_reports_each_failure_without_panicking() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let missing = std::env::temp_dir().join("photocraft-definitely-missing-file.psd").to_string_lossy().to_string();
let dir = std::env::temp_dir().to_string_lossy().to_string();
let n = app.open_paths(&[missing, String::new(), dir, "\u{0}".into()]);
@@ -237,7 +268,7 @@ fn os_open_events_open_files_with_paths() {
std::fs::write(&a, b"x").unwrap();
std::fs::write(&b, b"x").unwrap();
let paths: Vec<String> = [&a, &b].iter().map(|p| p.to_string_lossy().to_string()).collect();
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let mut queue = vec![OsEvent::Open(paths.clone())];
app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue)));
let ctx = egui::Context::default();
@@ -253,7 +284,7 @@ fn os_open_events_open_files_with_paths() {
#[test]
fn os_quit_and_empty_events_do_not_panic() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let mut queue = vec![OsEvent::Open(Vec::new()), OsEvent::Quit];
app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue)));
app.drain_os_events(&egui::Context::default());
@@ -283,7 +314,7 @@ fn doc_names(app: &PhotocraftApp) -> Vec<String> {
#[test]
fn dropped_files_open_with_path_and_recent() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
let dir = std::env::temp_dir().join(format!("photocraft-drop-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let abs = dir.join("dropped.psd");
@@ -322,7 +353,7 @@ fn png_bytes(w: u32, h: u32) -> Vec<u8> {
/// An app with one open document whose canvas showed at (100, 50)–(700, 550) last frame.
fn app_with_canvas() -> PhotocraftApp {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
app.open_bytes("canvas.psd", b"x").unwrap();
app.drop_canvas_rect = Some(egui::Rect::from_min_max(egui::pos2(100.0, 50.0), egui::pos2(700.0, 550.0)));
app
@@ -518,7 +549,7 @@ fn drag_and_drop_in_the_running_app() {
#[test]
fn views_and_windows_stay_with_their_documents() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
for name in ["a.psd", "b.psd", "c.psd", "d.psd"] {
app.open_bytes(name, b"x").unwrap();
}
@@ -573,7 +604,7 @@ fn files_dropped_off_the_canvas_open_as_documents() {
#[test]
fn notices_are_capped_and_dismissable_state_round_trips() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
for i in 0..10 {
notices::post(&mut app, format!("n{i}"), vec![], false, None);
}
@@ -588,7 +619,7 @@ fn notices_are_capped_and_dismissable_state_round_trips() {
#[test]
fn notices_render_without_panicking() {
let (mut app, _) = app_with(None, None);
let (mut app, _) = app_with(Vec::new());
notices::post(&mut app, "Opened a.psd with 9 warnings", (0..9).map(|i| format!("warning {i}")).collect(), false, None);
notices::error(&mut app, "Couldn't open b.psd: not an image".into());
let ctx = egui::Context::default();
+23 -16
View File
@@ -86,15 +86,13 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V
form(app, id, "Image Statistics", json!({"mode": "median", "input": dir, "align": false}), json!({"mode": modes}))
}
"file.scripts.browse" => {
let (name, bytes) = match app.pick_file_bytes()? {
Ok(picked) => picked,
Err(e) => return Some(Err(e)),
};
let r = app.run(id, json!({"script": String::from_utf8_lossy(&bytes)}));
if r.is_ok() {
app.ui.status = format!("Ran script {name}");
}
return Some(r);
return Some(app.pick_file_bytes(|app, name, bytes| {
let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)}));
if r.is_ok() {
app.ui.status = format!("Ran script {name}");
}
r
}));
}
"file.scripts.scriptEventsManager" => {
let enabled = app.session.prefs().script_events.enabled;
@@ -496,21 +494,30 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
fn web_confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let mut p = params(f);
if p.get("path").is_some() || p.get("dir").is_some() {
return save_for_web(app, p);
}
let doc = app.session.active().ok_or("no document")?.doc.clone();
let has_slices = !doc.slices.is_empty();
if p.get("path").is_none() && p.get("dir").is_none() {
let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?;
let base = photocraft_doc::slices::base_name(&doc);
let suggested = if has_slices && b(f, "html", true) { format!("{base}.html") } else { format!("{base}.{}", st.format.ext()) };
let path = app.services.pick_save.as_mut().and_then(|pick| pick(&suggested)).ok_or("cancelled")?;
let html = b(f, "html", true);
let st = WebSettings::from_params(&p, "file.export.saveForWebLegacy").map_err(|e| e.to_string())?;
let base = photocraft_doc::slices::base_name(&doc);
let suggested = if has_slices && html { format!("{base}.html") } else { format!("{base}.{}", st.format.ext()) };
app.pick_save(&suggested, move |app, path| {
app.refocus(doc.id)?;
if has_slices {
let dir = path.rfind(['/', '\\']).map(|i| path[..i].to_string()).unwrap_or_else(|| ".".into());
p["dir"] = json!(dir);
p["html"] = json!(b(f, "html", true));
p["html"] = json!(html);
} else {
p["path"] = json!(path);
}
}
save_for_web(app, p)
})
}
/// Runs Save for Web with `p`, which says where to write.
fn save_for_web(app: &mut PhotocraftApp, p: Value) -> Result<Value, String> {
let r = app.run("file.export.saveForWebLegacy", p)?;
app.ui.status = format!("Saved for Web: {} file(s)", r["files"].as_array().map_or(0, Vec::len));
Ok(r)
+34 -48
View File
@@ -55,6 +55,7 @@ pub mod dock;
pub mod enable_rules;
pub mod eraser_ui;
pub mod export_dialog;
pub mod file_dialog;
pub mod file_open;
pub mod file_ui;
pub mod fill_ui;
@@ -152,6 +153,7 @@ use photocraft_engine::Session;
use serde_json::Value;
pub use control::{ControlRequest, ControlResponse};
pub use file_dialog::{FileDialogAnswer, FileDialogFn, FileDialogReply, FileDialogRequest};
pub use file_open::OsEvent;
pub use state::{Tool, UiState};
@@ -182,12 +184,6 @@ impl Default for ExportSettings {
/// Encode a document: (file bytes, warnings about anything approximated or dropped).
pub type ExportFn = Box<dyn Fn(&Document, &str, &ExportSettings) -> Result<(Vec<u8>, Vec<String>), String>>;
/// The picked file's name and its bytes, or why it could not be read (shown like any other open
/// failure); `None` when the dialog was cancelled.
pub type PickOpenFn = Box<dyn FnMut() -> Option<(String, Result<Vec<u8>, String>)>>;
/// File › Open's multi-file picker: the selected paths, `None` when cancelled.
pub type PickOpenPathsFn = Box<dyn FnMut() -> Option<Vec<String>>>;
pub type PickSaveFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Read bytes through the desktop control session's authorized read root.
pub type AutomationReadFn = Box<dyn FnMut(&str) -> Result<(String, Vec<u8>), String>>;
@@ -242,16 +238,12 @@ pub struct Services {
pub import: Option<ImportFn>,
/// Encode a document for a file name (format chosen by extension).
pub export: Option<ExportFn>,
/// Show a single-file picker for commands that import one file (Open As, presets, scripts).
pub pick_open: Option<PickOpenFn>,
/// Show File › Open's multi-file picker; returns the selected paths.
pub pick_open_paths: Option<PickOpenPathsFn>,
/// Show a "save file" dialog; returns a path/name to write.
pub pick_save: Option<PickSaveFn>,
/// Show an Open or Save dialog without waiting for it (see `file_dialog`).
pub file_dialog: Option<FileDialogFn>,
/// Write bytes to a path (native) or trigger a download (web).
pub write: Option<WriteFn>,
/// File access used only by control/MCP requests. Interactive dialogs keep
/// using `pick_open`, `pick_save` and `write` with the user's authority.
/// using `file_dialog` and `write` with the user's authority.
pub automation_read: Option<AutomationReadFn>,
pub automation_write: Option<AutomationWriteFn>,
pub automation_command: Option<AutomationCommandFn>,
@@ -263,7 +255,7 @@ pub struct Services {
pub encode_png: Option<EncodePngFn>,
/// Open a URL in the system browser (native). Falls back to `ctx.open_url` (web) when unset.
pub open_url: Option<OpenUrlFn>,
/// Files delivered asynchronously (web file pickers, drag-and-drop): drained every frame.
/// Files delivered asynchronously (web drag-and-drop): drained every frame.
pub inbox: Option<Inbox>,
/// OS clipboard images: copies go out, screenshots and images from other apps come in.
pub clipboard_set_image: Option<ClipboardSetFn>,
@@ -458,6 +450,8 @@ pub struct PhotocraftApp {
pub(crate) prefs_rt: prefs_ui::Runtime,
/// Close, Revert or Exit parked behind the unsaved-changes prompt (see `discard_ui`).
pub(crate) discard: Option<discard_ui::Prompt>,
/// The Open or Save dialog in progress, and the action waiting on it (see `file_dialog`).
pub(crate) file_dialog: Option<file_dialog::Pending>,
/// A Save As to a layered TIFF parked behind the TIFF Options prompt (see `tiff_options_ui`).
pub(crate) tiff_options: Option<tiff_options_ui::Prompt>,
/// Set once the user has agreed to quit, so the resulting close request goes through.
@@ -555,6 +549,7 @@ impl PhotocraftApp {
perf: Default::default(),
prefs_rt: Default::default(),
discard: None,
file_dialog: None,
tiff_options: None,
allow_close: false,
stylus: Default::default(),
@@ -834,35 +829,17 @@ impl PhotocraftApp {
Ok(warnings)
}
/// File › Open: native platforms return all selected paths; the web delivers its pick through
/// the single-file service/inbox instead.
pub fn open_dialog_file(&mut self) {
if let Some(pick_paths) = self.services.pick_open_paths.as_mut() {
if let Some(paths) = pick_paths() {
self.open_paths(&paths);
}
return;
}
let Some((path, bytes)) = self.services.pick_open.as_mut().and_then(|f| f()) else { return };
if let Err(e) = bytes.and_then(|bytes| self.open_file(&path, &bytes)) {
self.open_failed(&file_open::display_name(&path), &e);
}
}
/// Show the open dialog for a file a command reads (a script, notes, a placed image, presets):
/// `None` when cancelled, else its name and bytes or the read error.
pub(crate) fn pick_file_bytes(&mut self) -> Option<Result<(String, Vec<u8>), String>> {
let (name, bytes) = self.services.pick_open.as_mut().and_then(|f| f())?;
Some(bytes.map(|b| (name.clone(), b)).map_err(|e| format!("{}: {e}", file_open::display_name(&name))))
}
/// Save the active document to `path` (or a path chosen in the save dialog); returns the path
/// and the export warnings (also shown to the user).
pub fn save_as(&mut self, path: Option<String>) -> Result<(String, Vec<String>), String> {
/// Save the active document to `path`, or to a path chosen in the save dialog (which
/// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export
/// warnings are also shown to the user).
pub fn save_as(&mut self, path: Option<String>) -> Result<Value, String> {
// Edit Contents documents save back into their smart object.
if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") {
self.run("layer.smartObjects.saveContents", serde_json::json!({}))?;
return Ok(("smart object".into(), Vec::new()));
return Ok(serde_json::json!({"path": "smart object", "warnings": []}));
}
if let Some(path) = path {
return self.save_to(path);
}
let st = self.session.active().ok_or("no document")?;
// Suggest the file's own name if Save As can write its format, otherwise switch to .psd.
@@ -874,17 +851,23 @@ impl PhotocraftApp {
Some(p) if writable => p.clone(),
p => std::path::Path::new(p.as_deref().unwrap_or(&st.doc.name)).with_extension("psd").to_string_lossy().into_owned(),
};
let path = match path {
Some(p) => p,
None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?,
};
let doc = st.doc.id;
self.pick_save(&suggested, move |app, path| {
app.refocus(doc)?;
app.save_to(path)
})
}
/// [`Self::save_as`] once the path is known.
fn save_to(&mut self, path: String) -> Result<Value, String> {
// A layered TIFF asks about its layers first (Preferences › File Handling); the save
// continues from the prompt.
if tiff_options_ui::wants_prompt(self, &path) {
tiff_options_ui::park(self, path.clone());
return Ok((path, Vec::new()));
return Ok(serde_json::json!({"path": path, "warnings": []}));
}
self.write_document(path, &ExportSettings::default())
let (path, warnings) = self.write_document(path, &ExportSettings::default())?;
Ok(serde_json::json!({"path": path, "warnings": warnings}))
}
/// Encodes the active document with `settings` and writes it to `path`, which becomes the
@@ -998,7 +981,7 @@ impl PhotocraftApp {
}
impl eframe::App for PhotocraftApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language));
if !self.styled {
Self::setup_context(ctx, self.ui.theme);
@@ -1067,6 +1050,7 @@ impl eframe::App for PhotocraftApp {
if !self.pending_screenshots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
self.poll_file_dialog(ctx, Some(frame));
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@@ -1082,7 +1066,7 @@ impl eframe::App for PhotocraftApp {
raw_input.events.extend(self.take_synthetic_step());
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language));
// Fonts registered via set_fonts only take effect next frame; named families would panic now.
@@ -1152,6 +1136,8 @@ impl eframe::App for PhotocraftApp {
let _ = w.send(serde_json::json!({"ok": true, "result": null}));
}
}
// Menus and buttons in this frame may have asked for a file dialog.
self.poll_file_dialog(&ctx, Some(frame));
}
}
+3 -4
View File
@@ -223,8 +223,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
if let Some(path) = params.get("path").and_then(Value::as_str) {
open_path(app, path)
} else {
app.open_dialog_file();
Ok(Value::Null)
app.open_dialog_file()
}
}
"file.save" => {
@@ -234,7 +233,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| photocraft_engine::file_cmds::saves_in_place(p)));
app.save_as(path).map(|(p, w)| json!({"path": p, "warnings": w}))
app.save_as(path)
}
"file.exit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
@@ -258,7 +257,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
Err("Open Recent is unavailable on the web".to_string())
}
}
"file.saveAs" => app.save_as(params.get("path").and_then(Value::as_str).map(str::to_string)).map(|(p, w)| json!({"path": p, "warnings": w})),
"file.saveAs" => app.save_as(params.get("path").and_then(Value::as_str).map(str::to_string)),
"view.zoomIn" | "view.zoomOut" | "view.fitOnScreen" | "view.actualPixels" => {
let i = app.session.active_index().ok_or("no document")?;
let v = &mut app.ui.views[i];
+11 -9
View File
@@ -1313,7 +1313,7 @@ fn presets_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String,
}
// Load Photoshop brushes (.abr) into the library.
if kind == "brushes" && ui.button(tl!("Load…")).on_hover_text(tl!("Import Photoshop brushes (.abr)")).clicked() {
app.open_dialog_file();
let _ = app.open_dialog_file();
}
});
f.insert("kind".into(), json!(kind));
@@ -1410,17 +1410,19 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
"presetsIO" => {
let kinds: Vec<&str> = ["brushes", "customShapes"].into_iter().filter(|k| f.get(*k).and_then(Value::as_bool).unwrap_or(true)).collect();
if f.get("action").and_then(Value::as_str) == Some("import") {
let (name, bytes) = app.pick_file_bytes().ok_or_else(|| "cancelled".to_string())??;
let text = String::from_utf8(bytes).map_err(|_| format!("{name} is not a preset file"))?;
app.run("edit.presets.exportImportPresets", json!({"action": "import", "kinds": kinds, "data": text}))
app.pick_file_bytes(move |app, name, bytes| {
let text = String::from_utf8(bytes).map_err(|_| format!("{name} is not a preset file"))?;
app.run("edit.presets.exportImportPresets", json!({"action": "import", "kinds": kinds, "data": text}))
})
} else {
let out = app.run("edit.presets.exportImportPresets", json!({"action": "export", "kinds": kinds}))?;
let text = serde_json::to_string_pretty(&out["data"]).map_err(|e| e.to_string())?;
let path = app.services.pick_save.as_mut().and_then(|p| p("Presets.pcpresets")).ok_or("cancelled")?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, text.as_bytes())?;
app.ui.status = format!("Exported presets to {path}");
Ok(json!({"path": path}))
app.pick_save("Presets.pcpresets", move |app, path| {
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, text.as_bytes())?;
app.ui.status = format!("Exported presets to {path}");
Ok(json!({"path": path}))
})
}
}
"mismatch" => {
+22 -20
View File
@@ -825,18 +825,19 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
let label = photocraft_engine::commands::find(id).map_or(id, |c| c.label);
let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)})));
match id {
"file.openAs" => {
app.open_dialog_file();
Some(Ok(Value::Null))
}
"file.openAs" => Some(app.open_dialog_file()),
"file.saveACopy" => Some(save_a_copy(app)),
"file.placeEmbedded" | "file.placeLinked" => {
let (name, bytes) = match app.pick_file_bytes()? {
Ok(picked) => picked,
let doc = match app.active_doc_id() {
Ok(doc) => doc,
Err(e) => return Some(Err(e)),
};
let linked = (id == "file.placeLinked").then(|| name.clone());
Some(app.place_bytes(&name, bytes, linked))
let linked = id == "file.placeLinked";
Some(app.pick_file_bytes(move |app, name, bytes| {
app.refocus(doc)?;
let linked = linked.then(|| name.clone());
app.place_bytes(&name, bytes, linked)
}))
}
"file.fileInfo" => {
let info = app.session.execute("file.fileInfo", json!({})).ok()?;
@@ -1007,19 +1008,20 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
/// File › Save a Copy: pick a name, encode with the export service, write; the document's path
/// and saved state are untouched.
fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a).to_string();
let suggested = format!("{stem} copy.psd");
let path = app.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?;
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let doc = app.session.active().ok_or("no document")?.doc.clone();
let (bytes, warnings) = export(&doc, &path, &crate::ExportSettings::default())?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
app.ui.status = format!("Saved a copy as {path}");
app.ui.status_error = false;
crate::notices::io_warnings(app, &format!("Saved a copy as {}", crate::file_open::display_name(&path)), &warnings);
Ok(json!({"path": path, "warnings": warnings}))
let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a);
let suggested = format!("{stem} copy.psd");
// The copy is of the document as it was when asked.
app.pick_save(&suggested, move |app, path| {
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(&doc, &path, &crate::ExportSettings::default())?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
app.ui.status = format!("Saved a copy as {path}");
app.ui.status_error = false;
crate::notices::io_warnings(app, &format!("Saved a copy as {}", crate::file_open::display_name(&path)), &warnings);
Ok(json!({"path": path, "warnings": warnings}))
})
}
#[cfg(test)]