mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 03:36:48 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f22ac99690 | ||
|
|
6517f52eb2 |
No files matched your search
Generated
+12
@@ -1537,6 +1537,17 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -3240,6 +3251,7 @@ dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"open",
|
||||
"photocraft-automation",
|
||||
|
||||
@@ -11,8 +11,9 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
/// Everything File › Open reads: PhotoCraft documents, Photoshop documents and flat images.
|
||||
const OPEN_EXTS: &[&str] =
|
||||
&["pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
|
||||
pub fn start() {
|
||||
@@ -101,19 +102,19 @@ impl eframe::App for WebShell {
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
|
||||
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| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
|
||||
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("Images", IMAGE_EXTS).pick_file().await else {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
|
||||
@@ -32,6 +32,12 @@ rfd = { workspace = true }
|
||||
arboard = "3"
|
||||
open = "5"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a PhotoCraft file.psd` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, and handling it ourselves needs Objective-C class declarations, i.e.
|
||||
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate wraps exactly that
|
||||
//! (an `NSAppleEventManager` handler registered before Finder's launch event, leaving winit's
|
||||
//! delegate alone) behind a safe main-thread API; this module adapts it to [`OsEvent`]s that the UI
|
||||
//! polls every frame.
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use photocraft_ui_egui::OsEvent;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the UI drains (see `Services::os_events`); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> photocraft_ui_egui::OsEventsFn {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
let inbox = self.inbox.clone();
|
||||
Box::new(move || {
|
||||
inbox
|
||||
.drain()
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
Event::Open(paths) => OsEvent::Open(paths.into_iter().map(|p| p.to_string_lossy().into_owned()).collect()),
|
||||
Event::Quit => OsEvent::Quit,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-12
@@ -12,6 +12,8 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod services;
|
||||
@@ -74,6 +76,12 @@ fn main() -> eframe::Result {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
|
||||
// launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
@@ -105,19 +113,12 @@ fn main() -> eframe::Result {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in files {
|
||||
match std::fs::read(&f) {
|
||||
Ok(bytes) => {
|
||||
let name = std::path::Path::new(&f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(f.clone());
|
||||
if let Err(e) = app.open_bytes(&name, &bytes) {
|
||||
eprintln!("photocraft: {f}: {e}");
|
||||
} else if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(f.clone());
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("photocraft: {f}: {e}"),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,8 +12,26 @@ use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
const IMAGE_EXTS: &[&str] =
|
||||
&["psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
/// Everything File › Open reads: PhotoCraft documents, Photoshop documents and flat images.
|
||||
const OPEN_EXTS: &[&str] =
|
||||
&["pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm"];
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
|
||||
/// keeps defaulting to Photoshop.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] =
|
||||
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
|
||||
|
||||
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
|
||||
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
|
||||
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let mut v = SAVE_FILTERS.to_vec();
|
||||
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
@@ -55,28 +73,26 @@ pub fn native() -> Services {
|
||||
let savers2 = savers.clone();
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))
|
||||
crate::crash_guard::guard("Open", || 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| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string()))
|
||||
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("Images", IMAGE_EXTS).pick_file()?;
|
||||
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
|
||||
let bytes = std::fs::read(&path).ok()?;
|
||||
Some((path.to_string_lossy().to_string(), bytes))
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let p = std::path::Path::new(suggested);
|
||||
let mut d = rfd::FileDialog::new()
|
||||
.add_filter("Photoshop", &["psd", "psb"])
|
||||
.add_filter("PNG", &["png"])
|
||||
.add_filter("JPEG", &["jpg"])
|
||||
.add_filter("TIFF", &["tif"])
|
||||
.add_filter("OpenEXR", &["exr"]);
|
||||
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());
|
||||
}
|
||||
@@ -129,6 +145,8 @@ pub fn native() -> Services {
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
|
||||
f.write_all(text.as_bytes()).map_err(|e| e.to_string())
|
||||
})),
|
||||
// Set by main once the Apple-event handlers are connected (macOS).
|
||||
os_events: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,15 @@ fn main() {
|
||||
arg(&args, "--script").map(|s| serde_json::from_str::<Vec<(String, Value)>>(&s).expect("--script must be [[method, params], …]")).unwrap_or_default();
|
||||
|
||||
let services = Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
|
||||
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: &photocraft_doc::Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
|
||||
..Default::default()
|
||||
};
|
||||
let open = arg(&args, "--open");
|
||||
@@ -47,9 +48,7 @@ fn main() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
if let Some(path) = &open {
|
||||
let bytes = std::fs::read(path).expect("read --open file");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.open_bytes(&name, &bytes).expect("open file");
|
||||
app.open_path(path).expect("open --open file");
|
||||
}
|
||||
app
|
||||
});
|
||||
|
||||
@@ -311,7 +311,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
Some(path) => wrap(crate::menus::invoke(app, ctx, "file.open", json!({"path": path}))),
|
||||
None => err("missing `path`"),
|
||||
},
|
||||
"app.save" => wrap(app.save_as(s("path").map(str::to_string)).map(|p| json!({"path": p}))),
|
||||
"app.save" => wrap(app.save_as(s("path").map(str::to_string)).map(|(p, w)| json!({"path": p, "warnings": w}))),
|
||||
"app.quit" => {
|
||||
app.allow_close = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -336,6 +336,8 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
"windows": app.ui.windows,
|
||||
"theme": app.ui.theme,
|
||||
"status": app.ui.status,
|
||||
"statusError": app.ui.status_error,
|
||||
"notices": app.ui.notices,
|
||||
"frame": app.frame,
|
||||
"session": photocraft_engine::inspect::session(&app.session),
|
||||
"document": app.session.active().map(photocraft_engine::inspect::document),
|
||||
|
||||
@@ -103,7 +103,7 @@ fn settings(f: &Map<String, Value>) -> ExportSettings {
|
||||
fn estimate(app: &PhotocraftApp, doc: &Document, f: &Map<String, Value>) -> Option<u64> {
|
||||
let export = app.services.export.as_ref()?;
|
||||
let proxy = export_document(doc, f, Some(512)).ok()?;
|
||||
let bytes = export(&proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?;
|
||||
let (bytes, _) = export(&proxy, &format!("estimate.{}", s_fmt(f)), &settings(f)).ok()?;
|
||||
let scale = n(f, "scale", 100.0) / 100.0;
|
||||
let full = doc.size.width as f64 * scale * doc.size.height as f64 * scale;
|
||||
let small = (proxy.size.width as f64 * proxy.size.height as f64).max(1.0);
|
||||
@@ -191,11 +191,13 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
|
||||
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 = export(&out, &path, &settings(f))?;
|
||||
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));
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
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
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Opening files from disk: File › Open, Open Recent, drag-and-drop onto the window, command-line
|
||||
//! paths at launch and the OS's "open these documents" requests (macOS Finder double-click, Open
|
||||
//! With, drops on the Dock icon) all go through [`PhotocraftApp::open_file`], so each one names the
|
||||
//! document after the file, remembers its path (File › Save writes back to it) and adds it to Open
|
||||
//! Recent. Failures are shown as errors (status bar + notice); import warnings as a notice.
|
||||
|
||||
use crate::{PhotocraftApp, notices};
|
||||
|
||||
/// A request from the operating system, delivered by the platform shell through
|
||||
/// [`Services::os_events`](crate::Services::os_events).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OsEvent {
|
||||
/// Open these files (absolute paths), e.g. a Finder double-click.
|
||||
Open(Vec<String>),
|
||||
/// Quit (e.g. the Dock menu's Quit, log out): goes through the normal close path, which asks
|
||||
/// about unsaved changes.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// The file name of `path` (the document's display name), or `path` itself when it has none.
|
||||
pub fn display_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Open `bytes` read from the file at `path`: the document is named after the file, keeps
|
||||
/// `path` for File › Save, and `path` goes to the top of Open Recent. Returns the import
|
||||
/// warnings (also shown to the user).
|
||||
pub fn open_file(&mut self, path: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
let warnings = self.open_bytes(&display_name(path), bytes)?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
st.path = Some(path.to_string());
|
||||
}
|
||||
self.push_recent(path);
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// Read and open the file at `path` (see [`open_file`](Self::open_file)).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path(&mut self, path: &str) -> Result<Vec<String>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
self.open_file(path, &bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn open_path(&mut self, path: &str) -> Result<Vec<String>, String> {
|
||||
Err(format!("cannot open paths on the web: {path}"))
|
||||
}
|
||||
|
||||
/// Open several files, reporting each failure to the user instead of stopping. Relative paths
|
||||
/// are resolved against the working directory so the remembered path stays valid. Returns how
|
||||
/// many opened.
|
||||
pub fn open_paths(&mut self, paths: &[String]) -> usize {
|
||||
let mut opened = 0;
|
||||
for p in paths {
|
||||
let abs = absolute(p);
|
||||
match self.open_path(&abs) {
|
||||
Ok(_) => opened += 1,
|
||||
Err(e) => self.open_failed(&display_name(p), &e),
|
||||
}
|
||||
}
|
||||
opened
|
||||
}
|
||||
|
||||
/// Show "Couldn't open <name>: <error>" as an error (status bar + notice).
|
||||
pub fn open_failed(&mut self, name: &str, err: &str) {
|
||||
let msg = format!("Couldn't open {name}: {err}");
|
||||
log::warn!("{msg}");
|
||||
notices::error(self, msg);
|
||||
}
|
||||
|
||||
/// Files dropped onto the window. Native drops carry an absolute path and open like File ›
|
||||
/// Open; anything else (no path) opens by name only.
|
||||
pub fn open_dropped(&mut self, files: Vec<egui::DroppedFileHandle>) {
|
||||
for f in files {
|
||||
let path = f.path().to_string_lossy().to_string();
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
let opened =
|
||||
crate::read_dropped(&*f).and_then(|bytes| if f.path().is_absolute() { self.open_file(&path, &bytes) } else { self.open_bytes(&name, &bytes) });
|
||||
if let Err(e) = opened {
|
||||
self.open_failed(&name, &e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the OS requests queued since the last frame.
|
||||
pub(crate) fn drain_os_events(&mut self, ctx: &egui::Context) {
|
||||
let events = self.services.os_events.as_mut().map(|f| f()).unwrap_or_default();
|
||||
for e in events {
|
||||
match e {
|
||||
OsEvent::Open(paths) => {
|
||||
self.open_paths(&paths);
|
||||
// Bring the window forward when another app (Finder, the Dock) asked us to open.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn absolute(p: &str) -> String {
|
||||
std::path::absolute(p).map(|a| a.to_string_lossy().to_string()).unwrap_or_else(|_| p.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn absolute(p: &str) -> String {
|
||||
p.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,247 @@
|
||||
use super::*;
|
||||
use crate::{Services, menus, notices};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_geom::Size;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Writes recorded by the fake writer: (path, bytes).
|
||||
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) {
|
||||
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" {
|
||||
return Err("not an image".into());
|
||||
}
|
||||
let warnings = if name.contains("warn") { vec!["Adjustment layer \"Curves 1\" was flattened".to_string()] } else { Vec::new() };
|
||||
Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), warnings))
|
||||
})),
|
||||
export: Some(Box::new(|_doc: &Document, path: &str, _s: &crate::ExportSettings| {
|
||||
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())),
|
||||
pick_save: Some(Box::new(move |_s: &str| pick_save.clone())),
|
||||
write: Some(Box::new(move |p: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((p.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(PhotocraftApp::new(photocraft_engine::Session::new(), services), written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_is_the_file_name() {
|
||||
assert_eq!(display_name("/a/b/photo.psd"), "photo.psd");
|
||||
assert_eq!(display_name("photo.psd"), "photo.psd");
|
||||
assert_eq!(display_name(""), "");
|
||||
assert_eq!(display_name("/"), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_sets_name_path_and_recent() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
let w = app.open_file("/pics/cat.psd", b"x").unwrap();
|
||||
assert!(w.is_empty());
|
||||
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, vec!["/pics/cat.psd".to_string()]);
|
||||
assert!(!app.ui.status_error);
|
||||
assert!(app.ui.notices.is_empty());
|
||||
}
|
||||
|
||||
#[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 ctx = egui::Context::default();
|
||||
menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap();
|
||||
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).
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcraft_documents_save_in_place_but_flat_files_ask() {
|
||||
let (mut app, written) = app_with(None, 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();
|
||||
assert_eq!(r["path"], "/pics/work.pcraft");
|
||||
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!(written.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[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 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");
|
||||
assert!(app.ui.status.contains("was flattened"), "{}", app.ui.status);
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.ui.notices.len(), 1);
|
||||
assert!(!app.ui.notices[0].error);
|
||||
assert_eq!(app.ui.notices[0].lines.len(), 1);
|
||||
// The document still opened, with its path.
|
||||
assert_eq!(app.session.active().and_then(|s| s.path.clone()), Some(p));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_warnings_reach_status_notice_and_control_response() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
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();
|
||||
assert_eq!(r["path"], "/pics/cat.png");
|
||||
assert_eq!(r["warnings"][0], "Layers were flattened");
|
||||
assert!(app.ui.status.contains("Saved cat.png"), "{}", app.ui.status);
|
||||
assert_eq!(app.ui.notices.len(), 1);
|
||||
// No warnings: no notice, and the response says so.
|
||||
let r = menus::invoke(&mut app, &ctx, "file.saveAs", json!({ "path": "/pics/cat.psd" })).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
assert_eq!(app.ui.notices.len(), 1);
|
||||
}
|
||||
|
||||
#[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 ctx = egui::Context::default();
|
||||
menus::invoke(&mut app, &ctx, "file.open", json!({})).unwrap();
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_reports_each_failure_without_panicking() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
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()]);
|
||||
assert_eq!(n, 0);
|
||||
assert!(app.session.documents().is_empty());
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.ui.notices.len(), notices::MAX_NOTICES);
|
||||
// Bad control params error instead of panicking.
|
||||
let ctx = egui::Context::default();
|
||||
assert!(menus::invoke(&mut app, &ctx, "file.open", json!({ "path": "" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_open_events_open_files_with_paths() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-os-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let a = dir.join("a.psd");
|
||||
let b = dir.join("b.png");
|
||||
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 queue = vec![OsEvent::Open(paths.clone())];
|
||||
app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue)));
|
||||
let ctx = egui::Context::default();
|
||||
app.drain_os_events(&ctx);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.session.active().and_then(|s| s.path.clone()).as_deref(), Some(paths[1].as_str()));
|
||||
assert_eq!(app.ui.recent_files, vec![paths[1].clone(), paths[0].clone()]);
|
||||
// Drained: a second poll opens nothing more.
|
||||
app.drain_os_events(&ctx);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_quit_and_empty_events_do_not_panic() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
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());
|
||||
assert!(app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FakeDrop(std::path::PathBuf, Result<Vec<u8>, String>);
|
||||
|
||||
impl egui::DroppedFile for FakeDrop {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
self.1.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_files_open_with_path_and_recent() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
let abs = std::env::temp_dir().join("dropped.psd");
|
||||
let abs_s = abs.to_string_lossy().to_string();
|
||||
app.open_dropped(vec![
|
||||
std::sync::Arc::new(FakeDrop(abs, Ok(b"x".to_vec()))),
|
||||
// Unreadable and undecodable drops are errors; no path means no recent entry.
|
||||
std::sync::Arc::new(FakeDrop("gone.psd".into(), Err("permission denied".into()))),
|
||||
std::sync::Arc::new(FakeDrop("rel.psd".into(), Ok(b"x".to_vec()))),
|
||||
std::sync::Arc::new(FakeDrop("".into(), Ok(b"bad".to_vec()))),
|
||||
]);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.session.documents()[0].path.as_deref(), Some(abs_s.as_str()));
|
||||
assert_eq!(app.session.documents()[0].doc.name, "dropped.psd");
|
||||
assert_eq!(app.session.documents()[1].path, None);
|
||||
assert_eq!(app.ui.recent_files, vec![abs_s]);
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(app.ui.notices.iter().filter(|n| n.error).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_are_capped_and_dismissable_state_round_trips() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
for i in 0..10 {
|
||||
notices::post(&mut app, format!("n{i}"), vec![], false);
|
||||
}
|
||||
assert_eq!(app.ui.notices.len(), notices::MAX_NOTICES);
|
||||
assert_eq!(app.ui.notices.last().map(|n| n.title.as_str()), Some("n9"));
|
||||
// Notices are part of the serialized UI state (ui.inspect / ui.set).
|
||||
let v = serde_json::to_value(&app.ui).unwrap();
|
||||
assert_eq!(v["notices"].as_array().map(Vec::len), Some(notices::MAX_NOTICES));
|
||||
let back: crate::UiState = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.notices, app.ui.notices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_render_without_panicking() {
|
||||
let (mut app, _) = app_with(None, None);
|
||||
notices::post(&mut app, "Opened a.psd with 9 warnings", (0..9).map(|i| format!("warning {i}")).collect(), false);
|
||||
notices::error(&mut app, "Couldn't open b.psd: not an image".into());
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| notices::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(app.ui.notices.len(), 2);
|
||||
}
|
||||
+43
-31
@@ -26,6 +26,7 @@ pub mod distort_ui;
|
||||
pub mod doc_props_ui;
|
||||
pub mod enable_rules;
|
||||
pub mod export_dialog;
|
||||
pub mod file_open;
|
||||
pub mod file_ui;
|
||||
pub mod filter_dialog;
|
||||
pub mod gallery_ui;
|
||||
@@ -40,6 +41,7 @@ pub mod liquify_ui;
|
||||
pub mod menu_catalog;
|
||||
pub mod menus;
|
||||
pub mod new_doc_ui;
|
||||
pub mod notices;
|
||||
pub mod outline;
|
||||
pub mod palette;
|
||||
pub mod panels;
|
||||
@@ -78,9 +80,11 @@ use photocraft_engine::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use file_open::OsEvent;
|
||||
pub use state::{Tool, UiState};
|
||||
|
||||
pub type ImportFn = Box<dyn Fn(&str, &[u8]) -> Result<Document, String>>;
|
||||
/// Decode a file: (document, warnings about anything approximated or dropped).
|
||||
pub type ImportFn = Box<dyn Fn(&str, &[u8]) -> Result<(Document, Vec<String>), String>>;
|
||||
/// Encoder settings chosen in Export As (the file format comes from the name's extension).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ExportSettings {
|
||||
@@ -88,7 +92,8 @@ pub struct ExportSettings {
|
||||
pub jpeg_quality: Option<u8>,
|
||||
}
|
||||
|
||||
pub type ExportFn = Box<dyn Fn(&Document, &str, &ExportSettings) -> Result<Vec<u8>, String>>;
|
||||
/// 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>>;
|
||||
pub type PickOpenFn = Box<dyn FnMut() -> Option<(String, Vec<u8>)>>;
|
||||
pub type PickSaveFn = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
@@ -113,6 +118,8 @@ pub type DiscardAutosaveFn = Box<dyn FnMut(u64)>;
|
||||
pub type RecoverFn = Box<dyn FnMut() -> Vec<(Option<String>, Document)>>;
|
||||
/// Append text to a file (History Log).
|
||||
pub type AppendTextFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
|
||||
/// Requests from the operating system since the last call (see [`OsEvent`]).
|
||||
pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
|
||||
|
||||
/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of
|
||||
/// I/O dependencies.
|
||||
@@ -147,6 +154,8 @@ pub struct Services {
|
||||
pub recover: Option<RecoverFn>,
|
||||
/// History Log text file output.
|
||||
pub append_text: Option<AppendTextFn>,
|
||||
/// OS requests (macOS open-documents / quit Apple events), polled every frame.
|
||||
pub os_events: Option<OsEventsFn>,
|
||||
}
|
||||
|
||||
pub struct PhotocraftApp {
|
||||
@@ -347,13 +356,19 @@ impl PhotocraftApp {
|
||||
r.truncate(10);
|
||||
}
|
||||
|
||||
pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
/// Open a file's bytes as a new document named `name`; returns the import warnings (also
|
||||
/// shown to the user). Files from disk go through [`open_file`](Self::open_file), which also
|
||||
/// remembers the path.
|
||||
pub fn open_bytes(&mut self, name: &str, bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
let import = self.services.import.as_ref().ok_or("no importer configured")?;
|
||||
let doc = import(name, bytes)?;
|
||||
let (doc, warnings) = import(name, bytes)?;
|
||||
// Edit › Color Settings policies apply on open; mismatches can ask what to do.
|
||||
let (_, color) = self.session.open_document(doc, Some(name.to_string()));
|
||||
// No path yet: a bare name isn't a location to save back to (`open_file` sets the path).
|
||||
let (_, color) = self.session.open_document(doc, None);
|
||||
self.sync_views();
|
||||
self.ui.status = format!("Opened {name}");
|
||||
self.ui.status_error = false;
|
||||
notices::io_warnings(self, &format!("Opened {name}"), &warnings);
|
||||
// Script events bound to "Open Document".
|
||||
photocraft_engine::automate_cmds::document_opened(&mut self.session);
|
||||
self.sync_views();
|
||||
@@ -361,33 +376,37 @@ impl PhotocraftApp {
|
||||
if ask && (color.get("mismatch").and_then(Value::as_bool) == Some(true) || color.get("missing").is_some()) {
|
||||
prefs_ui::open_mismatch(self, &color);
|
||||
}
|
||||
Ok(())
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// File › Open: the platform dialog returns the chosen file's path (native; the web delivers
|
||||
/// picks through the inbox instead).
|
||||
pub fn open_dialog_file(&mut self) {
|
||||
let picked = self.services.pick_open.as_mut().and_then(|f| f());
|
||||
if let Some((name, bytes)) = picked {
|
||||
match self.open_bytes(&name, &bytes) {
|
||||
Ok(()) => self.push_recent(&name),
|
||||
Err(e) => self.ui.status = format!("Couldn't open {name}: {e}"),
|
||||
}
|
||||
if let Some((path, bytes)) = picked
|
||||
&& let Err(e) = self.open_file(&path, &bytes)
|
||||
{
|
||||
self.open_failed(&file_open::display_name(&path), &e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_as(&mut self, path: Option<String>) -> Result<String, String> {
|
||||
/// 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> {
|
||||
// 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());
|
||||
return Ok(("smart object".into(), Vec::new()));
|
||||
}
|
||||
let st = self.session.active().ok_or("no document")?;
|
||||
let suggested = st.path.clone().unwrap_or_else(|| format!("{}.psd", st.doc.name));
|
||||
// Documents are named after their file ("cat.png"): suggest "cat.psd", not "cat.png.psd".
|
||||
let suggested = st.path.clone().unwrap_or_else(|| format!("{}.psd", st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(stem, _)| stem)));
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?,
|
||||
};
|
||||
let export = self.services.export.as_ref().ok_or("no exporter configured")?;
|
||||
let bytes = export(&st.doc, &path, &ExportSettings::default())?;
|
||||
let (bytes, warnings) = export(&st.doc, &path, &ExportSettings::default())?;
|
||||
let write = self.services.write.as_mut().ok_or("no writer configured")?;
|
||||
write(&path, &bytes)?;
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
@@ -401,8 +420,10 @@ impl PhotocraftApp {
|
||||
{
|
||||
self.ui.status = format!("Saved {path}; {} image assets in {}", r["files"].as_array().map_or(0, Vec::len), r["dir"].as_str().unwrap_or(""));
|
||||
}
|
||||
self.ui.status_error = false;
|
||||
notices::io_warnings(self, &format!("Saved {}", file_open::display_name(&path)), &warnings);
|
||||
self.sync_views();
|
||||
Ok(path)
|
||||
Ok((path, warnings))
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
@@ -507,23 +528,13 @@ impl eframe::App for PhotocraftApp {
|
||||
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
|
||||
for (name, bytes) in arrived {
|
||||
if let Err(e) = self.open_bytes(&name, &bytes) {
|
||||
self.ui.status = format!("Couldn't open {name}: {e}");
|
||||
self.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
// Files dropped onto the window open as documents.
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match read_dropped(&*f) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = self.open_bytes(&name, &bytes) {
|
||||
self.ui.status = format!("Couldn't open {name}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => self.ui.status = format!("Couldn't read {name}: {e}"),
|
||||
self.open_failed(&name, &e);
|
||||
}
|
||||
}
|
||||
// Finder double-click / Open With / Dock drops (macOS open-documents events).
|
||||
self.drain_os_events(ctx);
|
||||
// Files dropped onto the window open as documents (with their path, like File › Open).
|
||||
self.open_dropped(ctx.input(|i| i.raw.dropped_files.clone()));
|
||||
// The control transport wakes the UI on arrival (ctx.request_repaint); only poll while a
|
||||
// screenshot is pending. (Polling every 50 ms here made idle apps render at 20 fps.)
|
||||
if !self.pending_screenshots.is_empty() {
|
||||
@@ -583,6 +594,7 @@ impl eframe::App for PhotocraftApp {
|
||||
camera_raw_ui::show(self, &ctx);
|
||||
wide_angle_ui::show(self, &ctx);
|
||||
canvas::extra_windows(self, &ctx);
|
||||
notices::show(self, &ctx);
|
||||
self.perf.frame(gpu_canvas::now_ms() - t0);
|
||||
// Synthetic input is injected one press/release step per frame: keep frames coming until
|
||||
// the queue is empty, then release control replies waiting on it.
|
||||
|
||||
+10
-16
@@ -213,8 +213,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| p.ends_with(".psd") || p.ends_with(".psb")));
|
||||
app.save_as(path).map(|p| json!({"path": p}))
|
||||
.or_else(|| app.session.active().and_then(|d| d.path.clone()).filter(|p| saves_in_place(p)));
|
||||
app.save_as(path).map(|(p, w)| json!({"path": p, "warnings": w}))
|
||||
}
|
||||
"file.exit" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -238,7 +238,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| json!({"path": p})),
|
||||
"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})),
|
||||
"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];
|
||||
@@ -389,21 +389,15 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_path(app: &mut PhotocraftApp, path: &str) -> Result<Value, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string());
|
||||
app.open_bytes(&name, &bytes)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
st.path = Some(path.to_string());
|
||||
}
|
||||
app.push_recent(path);
|
||||
Ok(Value::Null)
|
||||
app.open_path(path).map(|w| json!({"warnings": w}))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open_path(_app: &mut PhotocraftApp, path: &str) -> Result<Value, String> {
|
||||
Err(format!("cannot open paths on the web: {path}"))
|
||||
/// File › Save writes back to the document's own file for layered formats (PSD, PSB, .pcraft);
|
||||
/// flat files go through Save As, like Photoshop.
|
||||
fn saves_in_place(path: &str) -> bool {
|
||||
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
|
||||
matches!(ext.as_str(), "psd" | "psb" | "pcraft")
|
||||
}
|
||||
|
||||
pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
|
||||
@@ -959,7 +953,7 @@ mod open_recent_tests {
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_geom::Size;
|
||||
let services = crate::Services {
|
||||
import: Some(Box::new(|_n: &str, _b: &[u8]| Ok(photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8)))),
|
||||
import: Some(Box::new(|_n: &str, _b: &[u8]| Ok((photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Non-blocking notices: a small stack of cards in the lower-right corner of the window for things
|
||||
//! the user should see but that must not interrupt work (import/export warnings such as "adjustment
|
||||
//! flattened", files that couldn't be opened). Notices live in [`UiState`](crate::UiState) so the
|
||||
//! control channel can read them (`ui.inspect`) and clear them (`ui.set`).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// At most this many notices are kept; older ones drop off.
|
||||
pub const MAX_NOTICES: usize = 3;
|
||||
/// Lines shown per notice before "…and N more".
|
||||
const MAX_LINES: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Notice {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub lines: Vec<String>,
|
||||
/// An error (warning colour) rather than an informational notice.
|
||||
#[serde(default)]
|
||||
pub error: bool,
|
||||
}
|
||||
|
||||
/// Show a notice (newest last).
|
||||
pub fn post(app: &mut PhotocraftApp, title: impl Into<String>, lines: Vec<String>, error: bool) {
|
||||
let id = app.ui.alloc_id();
|
||||
app.ui.notices.push(Notice { id, title: title.into(), lines, error });
|
||||
let n = app.ui.notices.len();
|
||||
if n > MAX_NOTICES {
|
||||
app.ui.notices.drain(..n - MAX_NOTICES);
|
||||
}
|
||||
}
|
||||
|
||||
/// Report import/export `warnings` for the file operation `what` (e.g. "Opened a.psd"): the status
|
||||
/// bar says how many there were, and a notice lists them. Nothing happens when there are none.
|
||||
pub fn io_warnings(app: &mut PhotocraftApp, what: &str, warnings: &[String]) {
|
||||
let Some(first) = warnings.first() else { return };
|
||||
let n = warnings.len();
|
||||
app.ui.status = if n == 1 { format!("{what}: {first}") } else { format!("{what} with {n} warnings: {first} …") };
|
||||
app.ui.status_error = true;
|
||||
post(app, format!("{what} with {n} warning{}", if n == 1 { "" } else { "s" }), warnings.to_vec(), false);
|
||||
}
|
||||
|
||||
/// Report a failed file operation: the status bar shows it as an error and a notice keeps it on
|
||||
/// screen until dismissed.
|
||||
pub fn error(app: &mut PhotocraftApp, message: String) {
|
||||
app.ui.status = message.clone();
|
||||
app.ui.status_error = true;
|
||||
post(app, message, Vec::new(), true);
|
||||
}
|
||||
|
||||
/// Draw the notices; each has a close button.
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if app.ui.notices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ctx);
|
||||
let mut dismiss = None;
|
||||
// Clear the status bar (~24 px) and leave the dock's edge some air.
|
||||
egui::Area::new(egui::Id::new("photocraft-notices"))
|
||||
.order(egui::Order::Foreground)
|
||||
.anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -36.0))
|
||||
.interactable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(360.0);
|
||||
for n in &app.ui.notices {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(egui::Stroke::new(1.0, if n.error { t.warning } else { t.card_border }))
|
||||
.corner_radius(t.radius)
|
||||
.inner_margin(egui::Margin::same(10))
|
||||
.shadow(egui::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow })
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(340.0);
|
||||
ui.horizontal(|ui| {
|
||||
let title = egui::RichText::new(&n.title).strong().color(if n.error { t.warning } else { t.text });
|
||||
ui.add(egui::Label::new(title).wrap());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new("×").color(t.text_dim)).frame(false)).on_hover_text("Dismiss").clicked() {
|
||||
dismiss = Some(n.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
for line in n.lines.iter().take(MAX_LINES) {
|
||||
ui.add(egui::Label::new(egui::RichText::new(format!("• {line}")).color(t.text_dim)).wrap());
|
||||
}
|
||||
if n.lines.len() > MAX_LINES {
|
||||
ui.label(egui::RichText::new(format!("…and {} more", n.lines.len() - MAX_LINES)).color(t.text_faint));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
if let Some(id) = dismiss {
|
||||
app.ui.notices.retain(|n| n.id != id);
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,9 @@ pub struct UiState {
|
||||
/// The status message is an error (shown in the warning colour).
|
||||
#[serde(default)]
|
||||
pub status_error: bool,
|
||||
/// Non-blocking notices (import/export warnings, files that couldn't open), newest last.
|
||||
#[serde(default)]
|
||||
pub notices: Vec<crate::notices::Notice>,
|
||||
/// Status bar info field, Home screen (see `chrome_ui`).
|
||||
#[serde(default)]
|
||||
pub chrome: crate::chrome_ui::ChromeState,
|
||||
@@ -620,6 +623,7 @@ impl Default for UiState {
|
||||
next_id: 1,
|
||||
status: String::new(),
|
||||
status_error: false,
|
||||
notices: Vec::new(),
|
||||
chrome: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,11 +972,13 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
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 = export(&doc, &path, &crate::ExportSettings::default())?;
|
||||
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}");
|
||||
Ok(json!({"path": 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)]
|
||||
|
||||
@@ -45,7 +45,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.screenshot {path?, focus?}`: capture the main window (PNG). Raises the window first (default)
|
||||
because occluded macOS windows stop rendering
|
||||
- `ui.focus`: bring the main window to the front
|
||||
- `app.open {path}` / `app.save {path}`: file I/O through the configured services
|
||||
- `app.open {path}` / `app.save {path}`: file I/O through the configured services. Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.save` also returns the `path` written. `file.open`, `file.save`, `file.saveAs` and `file.saveACopy` reply the same way
|
||||
- `app.quit`
|
||||
|
||||
## Engine commands
|
||||
|
||||
Reference in new issue
Block a user