Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 f22ac99690 macOS: open files from Finder, Open With and the Dock (Apple events)
Finder double-click, Open With, drops on the Dock icon and open -a deliver
files as kAEOpenDocuments Apple events, not argv, and winit 0.30 ignores them.
Register an NSAppleEventManager handler through the small fmv-macos-events
crate (MIT OR Apache-2.0, 168 lines, audited, pinned =0.1.0; it keeps the
Objective-C unsafe out of our forbid(unsafe_code) workspace) before the event
loop starts, so both the launching event and later ones reach the UI as
OsEvent::Open; the window is focused. The quit Apple event goes through the
normal close path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 15:17:50 -04:00
Brandon ThomasandClaude Opus 5.5 6517f52eb2 File open/save: set the path everywhere, Open Recent, show errors and import/export warnings
File › Open, drag-and-drop onto the window and launch paths now share
PhotocraftApp::open_file: the document is named after the file (not the full
path), keeps its path so File › Save writes back, and goes to Open Recent.
Failures are shown as errors (status bar + a non-blocking notice).

Import/export warnings from photocraft-io are no longer thrown away: they show
in the status bar and in a dismissable notice (native and web), and the
control protocol returns them from app.open/app.save/file.open/file.save/
file.saveAs/file.saveACopy. .pcraft is in the Open and Save filters, and
File › Save writes .pcraft documents in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 15:17:50 -04:00
17 changed files with 645 additions and 87 deletions

No files matched your search

Generated
+12
View File
@@ -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",
+6 -5
View File
@@ -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;
+6
View File
@@ -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"
+45
View File
@@ -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
View File
@@ -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))
}),
)
+29 -11
View File
@@ -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,
}
}
+4 -5
View File
@@ -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
});
+3 -1
View File
@@ -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),
+5 -3
View File
@@ -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
+114
View File
@@ -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;
+247
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+99
View File
@@ -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);
}
}
+4
View File
@@ -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(),
}
}
+4 -2
View File
@@ -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)]
+1 -1
View File
@@ -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