mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 06:04:05 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a1cc948ba | ||
|
|
904a5eeea0 | ||
|
|
de0f3f5ffb | ||
|
|
34bec8d809 | ||
|
|
2bb3e677f7 |
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user