mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 05:34:16 -04:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbc434829 | ||
|
|
939ab1b978 | ||
|
|
b646566186 | ||
|
|
37e45bd412 | ||
|
|
7aff8a23fc | ||
|
|
dda8a7be1e | ||
|
|
e45d234e46 | ||
|
|
91ccbc4283 | ||
|
|
8ab39b8eec | ||
|
|
5804c487d1 | ||
|
|
249abdd57a | ||
|
|
8ae49e212a | ||
|
|
442908b2a9 | ||
|
|
db0a01078d | ||
|
|
b0e5be848f | ||
|
|
a7046a6caa | ||
|
|
14ed88e399 | ||
|
|
27653714e9 | ||
|
|
6ea2f563e7 | ||
|
|
9519112ca0 | ||
|
|
d077966b48 | ||
|
|
32d3a5af29 | ||
|
|
d4d419461b | ||
|
|
ea381870e7 | ||
|
|
691122fb1a | ||
|
|
2fcca8f3ae | ||
|
|
2052cf2fc8 | ||
|
|
b3f384a0fe | ||
|
|
2b6b59682e | ||
|
|
5a90ed192a | ||
|
|
15e102c414 | ||
|
|
461647811c | ||
|
|
e7c6850c1a | ||
|
|
a097239924 | ||
|
|
37f9c969a0 | ||
|
|
8bc2e8aa59 | ||
|
|
5da0d32ffb | ||
|
|
4c0c743d55 |
No files matched your search
@@ -158,11 +158,12 @@ fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R
|
||||
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
|
||||
fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
|
||||
let (s1, s2, s3) = (store.clone(), store.clone(), store.clone());
|
||||
let (s1, s2, s3, s4) = (store.clone(), store.clone(), store.clone(), store.clone());
|
||||
Services {
|
||||
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
|
||||
with_store(&s1, |s| s.autosave(doc, revision, path.map(str::to_string)))
|
||||
with_store(&s1, |s| s.autosave_checked(doc, revision, path.map(str::to_string)))?
|
||||
})),
|
||||
autosave_results: Some(Box::new(move || with_store(&s4, |s| s.take_completed()).unwrap_or_default())),
|
||||
discard_autosave: Some(Box::new(move |id: u64| {
|
||||
let _ = with_store(&s2, |s| s.discard(id));
|
||||
})),
|
||||
@@ -575,6 +576,54 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_background_autosave_is_reported_and_retried_without_another_edit() {
|
||||
let dir = temp("autosave-retry");
|
||||
let recovery = dir.join("Recovery");
|
||||
// A regular file prevents creation of the recovery bundle directory.
|
||||
std::fs::write(&recovery, b"blocked").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = launch(&recovery);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
autosave(&mut app, &ctx);
|
||||
|
||||
let mut failed = false;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
if app.ui.status.starts_with("Autosave failed:") {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(failed, "background write failures must reach the UI");
|
||||
assert!(app.ui.status_error);
|
||||
assert!(list_recovery(&recovery).is_empty());
|
||||
|
||||
// No edit occurs: retrying this very same revision must still work.
|
||||
std::fs::remove_file(&recovery).unwrap();
|
||||
std::fs::create_dir(&recovery).unwrap();
|
||||
autosave(&mut app, &ctx);
|
||||
let mut recovered = Vec::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
recovered = list_recovery(&recovery);
|
||||
if !recovered.is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(recovered.len(), 1, "unchanged revision should retry after failure");
|
||||
assert_eq!(recovered[0].info.revision, revision);
|
||||
let restored = photocraft_format::recover(&recovered[0]).unwrap();
|
||||
assert_eq!(photocraft_compose::flatten(&restored).px.first().copied(), Some(RED));
|
||||
drop(app);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
|
||||
@@ -142,10 +142,14 @@ impl Headless {
|
||||
warnings
|
||||
}
|
||||
};
|
||||
let is_native = format
|
||||
.map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("pcraft"))
|
||||
.unwrap_or_else(|| target.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcraft")));
|
||||
if is_native {
|
||||
// A layered document save (PSD, PSB or .pcraft) is a full write, not a flattened copy:
|
||||
// record the current revision as saved so the session stops reporting `dirty`, and make
|
||||
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
|
||||
// alone (#1547).
|
||||
let ext =
|
||||
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
|
||||
let layered = matches!(ext.as_deref(), Some("psd" | "psb")) || ext.as_deref() == Some(photocraft_format::EXTENSION);
|
||||
if layered {
|
||||
// Saving by index must not retarget the next automation command.
|
||||
let previously_active = self.session.active_index();
|
||||
self.session.set_active(i);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Regression for #1547: a successful save back to the document's own PSD or PSB file must
|
||||
//! record the current revision as saved, so the session stops reporting `dirty` (as `.pcraft`
|
||||
//! already did). A flat export stays a copy and leaves the dirty state and path alone.
|
||||
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pc-layered-save-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The issue's scenario: create `name`, reopen it, edit, then save with no path. The save writes
|
||||
/// the document's own file and must clear `dirty`.
|
||||
fn pathless_save_clears_dirty(dir: &std::path::Path, name: &str) {
|
||||
let path = dir.join(name);
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
headless.handle("doc.save", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
assert!(path.is_file(), "{name} was written");
|
||||
|
||||
// Reopen the saved file and edit it, so the pathless save below writes back over its own file.
|
||||
headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
|
||||
headless.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap();
|
||||
assert!(headless.session.active().unwrap().is_dirty(), "{name}: the edit dirties");
|
||||
|
||||
// The reported symptom: the save succeeds but the session still reports `dirty`.
|
||||
headless.handle("doc.save", json!({})).unwrap();
|
||||
assert!(!headless.session.active().unwrap().is_dirty(), "{name}: a pathless save is clean");
|
||||
let revision = headless.session.active().unwrap().revision;
|
||||
assert_eq!(headless.session.active().unwrap().saved_revision, revision, "{name}: the current revision is recorded");
|
||||
assert_eq!(headless.session.active().unwrap().path.as_deref(), path.to_str(), "{name}: the file is still the path");
|
||||
|
||||
// A second save, with no further edit, stays clean.
|
||||
headless.handle("doc.save", json!({})).unwrap();
|
||||
assert!(!headless.session.active().unwrap().is_dirty(), "{name}: a repeat save stays clean");
|
||||
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
if name.ends_with("psd") {
|
||||
assert!(photocraft_io::is_psd(&bytes), "{name}: PSD magic");
|
||||
} else {
|
||||
assert_eq!(&bytes[..4], b"8BPS", "{name}: PSB magic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathless_psd_and_psb_saves_clear_dirty() {
|
||||
let dir = tmp("clean");
|
||||
pathless_save_clears_dirty(&dir, "a.psd");
|
||||
pathless_save_clears_dirty(&dir, "a.psb");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The control from the issue: exporting a flat copy is not a document save, so it must neither
|
||||
/// clear `dirty` nor retarget the document's path. A `.pcraft` save sets the path on both the old
|
||||
/// and the new code, so this test isolates the flat-export behaviour.
|
||||
#[test]
|
||||
fn a_flat_export_is_a_copy_and_leaves_the_document_dirty() {
|
||||
let dir = tmp("copy");
|
||||
let native = dir.join("doc.pcraft");
|
||||
let png = dir.join("copy.png");
|
||||
let mut headless = Headless::trusted_local();
|
||||
headless.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
headless.handle("doc.save", json!({"path": native.to_str().unwrap()})).unwrap();
|
||||
headless.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap();
|
||||
assert!(headless.session.active().unwrap().is_dirty(), "the edit dirties");
|
||||
|
||||
headless.handle("doc.save", json!({"path": png.to_str().unwrap()})).unwrap();
|
||||
assert!(png.is_file(), "the PNG copy was written");
|
||||
assert!(headless.session.active().unwrap().is_dirty(), "a flat export leaves the document dirty");
|
||||
assert_eq!(headless.session.active().unwrap().path.as_deref(), native.to_str(), "the path is not retargeted to the copy");
|
||||
|
||||
// ...and the document can still be saved back to its own native file.
|
||||
headless.handle("doc.save", json!({})).unwrap();
|
||||
assert!(!headless.session.active().unwrap().is_dirty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -543,6 +543,14 @@ impl LayerContent {
|
||||
LayerContent::Smart(_) => "Smart Object",
|
||||
}
|
||||
}
|
||||
|
||||
/// English indefinite article for this layer kind ("an" for Adjustment, "a" for all others).
|
||||
pub fn article(&self) -> &'static str {
|
||||
match self {
|
||||
LayerContent::Adjustment(_) => "an",
|
||||
_ => "a",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1224,6 +1232,14 @@ mod tests {
|
||||
d.layer_mut(bg).unwrap().surface_mut().unwrap().write_pixel(0, 0, &[0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(snap.layers[0].surface().unwrap().pixel(0, 0), vec![1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_content_article_and_kind_name() {
|
||||
assert_eq!(LayerContent::Adjustment(Adjustment::Invert).article(), "an");
|
||||
assert_eq!(LayerContent::Adjustment(Adjustment::Invert).kind_name(), "Adjustment");
|
||||
assert_eq!(LayerContent::Raster(Surface::new(PixelFormat::RGBA8)).article(), "a");
|
||||
assert_eq!(LayerContent::Group(Group { children: Vec::new(), expanded: true, artboard: None }).article(), "a");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -204,7 +204,11 @@ fn has_pixels(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
if matches!(l.content, LayerContent::Raster(_)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -1649,6 +1649,6 @@ fn has_apply_target(s: &Session) -> std::result::Result<(), String> {
|
||||
if matches!(l.content, LayerContent::Raster(_)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Apply Image needs a pixel layer (active layer is a {} layer)", l.content.kind_name()))
|
||||
Err(format!("Apply Image needs a pixel layer (active layer is {} {} layer)", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,11 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
let l = crate::active_layer_of(s)?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
if matches!(l.content, LayerContent::Raster(_)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
/// A pixel layer, or a targeted alpha channel / Quick Mask (adjustments and fills apply to it).
|
||||
fn has_pixel_or_channel(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -57,7 +61,7 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> {
|
||||
if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is a {} layer without a mask", l.content.kind_name()))
|
||||
Err(format!("active layer is {} {} layer without a mask", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,8 +872,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
params: r##"{"document":index?}"##,
|
||||
enabled: has_doc,
|
||||
run: |s, p| {
|
||||
let i = p.get("document").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
let d = s.documents().get(i).ok_or(EngineError::NoDocument)?;
|
||||
let d = match p.get("document") {
|
||||
Some(v) => {
|
||||
let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad("document.inspect", "`document` must be an index"))?;
|
||||
s.documents().get(i).ok_or_else(|| EngineError::Other(format!("no document at index {i}")))?
|
||||
}
|
||||
None => s.active().ok_or(EngineError::NoDocument)?,
|
||||
};
|
||||
Ok(inspect::document(d))
|
||||
},
|
||||
journal: false,
|
||||
@@ -883,7 +892,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
enabled: has_doc,
|
||||
run: |s, p| {
|
||||
let i = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing `document`"))?;
|
||||
if s.set_active(i as usize) { Ok(Value::Null) } else { Err(EngineError::NoDocument) }
|
||||
let idx = usize::try_from(i).map_err(|_| bad("document.activate", "`document` out of range"))?;
|
||||
if s.set_active(idx) { Ok(Value::Null) } else { Err(EngineError::Other(format!("no document at index {idx}"))) }
|
||||
},
|
||||
journal: false,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ const REFINE: RefineParams = RefineParams { radius: 2.0, smart_radius: true, smo
|
||||
/// Remove Background needs an unlocked pixel layer.
|
||||
fn check(doc: &Document, l: &Layer) -> std::result::Result<(), String> {
|
||||
if !matches!(l.content, LayerContent::Raster(_)) {
|
||||
return Err(format!("the layer is a {} layer, not a pixel layer", l.content.kind_name()));
|
||||
return Err(format!("the layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()));
|
||||
}
|
||||
let locks = doc.effective_locks(l.id);
|
||||
if locks.pixels || locks.all {
|
||||
|
||||
@@ -201,7 +201,7 @@ fn pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
match &d.doc.layer(id).ok_or("no active layer")?.content {
|
||||
LayerContent::Raster(_) => Ok(()),
|
||||
LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()),
|
||||
other => Err(format!("needs a pixel layer or smart object (active layer is a {} layer)", other.kind_name())),
|
||||
other => Err(format!("needs a pixel layer or smart object (active layer is {} {} layer)", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ fn pixel_layer(s: &Session) -> std::result::Result<LayerId, String> {
|
||||
let id = d.active_layer.ok_or("no active layer")?;
|
||||
let l = d.doc.layer(id).ok_or("no active layer")?;
|
||||
if !matches!(l.content, LayerContent::Raster(_)) {
|
||||
return Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name()));
|
||||
return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()));
|
||||
}
|
||||
let locks = d.doc.effective_locks(id);
|
||||
if locks.all || locks.pixels {
|
||||
|
||||
@@ -47,7 +47,11 @@ fn enum_param<T: serde::de::DeserializeOwned>(p: &Value, cmd: &str, k: &str, def
|
||||
fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
if matches!(l.content, LayerContent::Raster(_)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
|
||||
|
||||
@@ -29,7 +29,7 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
fn has_pixels(s: &Session) -> std::result::Result<(), String> {
|
||||
match active_layer(s)?.content {
|
||||
LayerContent::Raster(_) => Ok(()),
|
||||
ref c => Err(format!("active layer is a {} layer, not a pixel layer", c.kind_name())),
|
||||
ref c => Err(format!("active layer is {} {} layer, not a pixel layer", c.article(), c.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), Strin
|
||||
match &l.content {
|
||||
LayerContent::Raster(_) => Ok(()),
|
||||
LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()),
|
||||
other => Err(format!("filters need a pixel layer (active layer is a {} layer)", other.kind_name())),
|
||||
other => Err(format!("filters need a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -704,13 +704,18 @@ impl Session {
|
||||
};
|
||||
self.coalesce_request = None;
|
||||
self.color_restrict = None;
|
||||
// Finish successful command bookkeeping on the document the job edited.
|
||||
// Restoring the viewed document first would attribute Fade and slice updates
|
||||
// to that document instead, when the user switched tabs mid-job.
|
||||
if r.is_ok() {
|
||||
self.after_command(command, params.clone(), *journal);
|
||||
}
|
||||
// Keep the user's active document unless the job opened a new one.
|
||||
// This also restores the selection when the apply step returned an error.
|
||||
if target.is_some() && self.active == target {
|
||||
self.active = prev_active.filter(|i| *i < self.docs.len()).or(self.active);
|
||||
}
|
||||
let v = r?;
|
||||
self.after_command(command, params.clone(), *journal);
|
||||
Ok(v)
|
||||
r
|
||||
}
|
||||
|
||||
fn end_job(&mut self, job: &Running, outcome: JobOutcome) {
|
||||
|
||||
@@ -220,6 +220,58 @@ fn a_running_job_locks_its_document_but_not_others() {
|
||||
assert_eq!(s.documents()[0].history.past_len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_job_finishes_bookkeeping_on_edited_document_after_tab_switch() {
|
||||
let mut s = session(64, 64);
|
||||
let edited_doc = s.active().unwrap().doc.id;
|
||||
let gate = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let worker_gate = Arc::clone(&gate);
|
||||
// Hold a fadeable filter job until another document is the active tab.
|
||||
// A deterministic gate avoids relying on the filter being sufficiently slow.
|
||||
let id = job(s
|
||||
.start_job(
|
||||
"filter.blur.gaussianBlur",
|
||||
json!({"radius": 2}),
|
||||
"Deferred Blur",
|
||||
true,
|
||||
move |ctx| {
|
||||
while !worker_gate.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
ctx.check()?;
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|s, ()| {
|
||||
s.edit("Deferred Blur", |doc, active| {
|
||||
let layer = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let surface = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?.surface_mut().ok_or(EngineError::NoLayer(layer))?;
|
||||
surface.fill_rect(photocraft_geom::Rect::new(0, 0, 1, 1), &[0.4, 0.3, 0.2, 1.0]);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"filter": {"radius": 2}}))
|
||||
},
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let viewed_doc = s.active().unwrap().doc.id;
|
||||
let viewed_revision = s.active().unwrap().revision;
|
||||
assert_eq!(s.active_index(), Some(1));
|
||||
|
||||
gate.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let event = wait_event(&mut s, id);
|
||||
assert!(matches!(event.outcome, JobOutcome::Done(_)), "{event:?}");
|
||||
assert_eq!(s.active_index(), Some(1), "the user stays on the selected document");
|
||||
assert_eq!(s.documents()[1].revision, viewed_revision, "the viewed document is unchanged");
|
||||
assert_eq!(s.documents()[1].doc.id, viewed_doc);
|
||||
assert_eq!(s.edit_state.fade.as_ref().map(|fade| fade.doc), Some(edited_doc), "Fade belongs to the edited document");
|
||||
assert!(!s.is_enabled("edit.fade"), "Fade must not target the viewed document");
|
||||
|
||||
assert!(s.set_active(0));
|
||||
assert!(s.is_enabled("edit.fade"), "Fade must be available on the document that was edited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_document_cancels_its_job() {
|
||||
let mut s = session(1600, 1200);
|
||||
|
||||
@@ -38,7 +38,11 @@ fn has_layer(s: &Session) -> Enabled {
|
||||
|
||||
fn has_raster(s: &Session) -> Enabled {
|
||||
let l = active_layer(s)?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
if matches!(l.content, LayerContent::Raster(_)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
fn has_raster_with_mask(s: &Session) -> Enabled {
|
||||
|
||||
@@ -262,7 +262,7 @@ fn filterable(s: &Session) -> std::result::Result<(), String> {
|
||||
match &d.doc.layer(id).ok_or("no active layer")?.content {
|
||||
LayerContent::Raster(_) => Ok(()),
|
||||
LayerContent::Smart(sm) if sm.cache.is_some() => Ok(()),
|
||||
other => Err(format!("filters need a pixel layer (active layer is a {} layer)", other.kind_name())),
|
||||
other => Err(format!("filters need a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
crate::smart_cmds::add_smart_filter(doc, layer, sf, selection.as_ref())
|
||||
}
|
||||
other => Err(EngineError::Other(format!("plug-in filters need a pixel layer (active layer is a {} layer)", other.kind_name()))),
|
||||
other => Err(EngineError::Other(format!("plug-in filters need a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name()))),
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"layer": layer.0, "plugin": id}))
|
||||
|
||||
@@ -42,7 +42,7 @@ fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name()))
|
||||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -890,7 +890,7 @@ fn active_smart(s: &Session) -> std::result::Result<&SmartObject, String> {
|
||||
let id = d.active_layer.ok_or("no active layer")?;
|
||||
match &d.doc.layer(id).ok_or("no active layer")?.content {
|
||||
LayerContent::Smart(sm) => Ok(sm),
|
||||
other => Err(format!("the active layer is a {} layer, not a smart object", other.kind_name())),
|
||||
other => Err(format!("the active layer is {} {} layer, not a smart object", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
fn has_smart(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -188,9 +188,9 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {
|
||||
let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let src_layer = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let src = src_layer
|
||||
.surface()
|
||||
.ok_or_else(|| EngineError::Other(format!("the active layer is a {} layer without pixels", src_layer.content.kind_name())))?;
|
||||
let src = src_layer.surface().ok_or_else(|| {
|
||||
EngineError::Other(format!("the active layer is {} {} layer without pixels", src_layer.content.article(), src_layer.content.kind_name()))
|
||||
})?;
|
||||
let name = d.doc.copy_name(&src_layer.name);
|
||||
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
|
||||
let reg = region.as_ref().unwrap_or(&empty);
|
||||
|
||||
@@ -1086,3 +1086,37 @@ fn move_document_reorders_tabs_and_keeps_the_active_one() {
|
||||
}
|
||||
assert_eq!(names(&s), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustment_layer_disabled_diagnostics_use_grammatical_article() {
|
||||
let mut s = session_with_doc();
|
||||
s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap();
|
||||
|
||||
let err_blur = s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap_err().to_string();
|
||||
assert!(err_blur.contains("an Adjustment layer"), "expected 'an Adjustment layer', got: {err_blur}");
|
||||
assert!(!err_blur.contains("a Adjustment layer"), "found 'a Adjustment layer': {err_blur}");
|
||||
|
||||
let err_equalize = s.execute("image.adjustments.equalize", json!({})).unwrap_err().to_string();
|
||||
assert!(err_equalize.contains("an Adjustment layer"), "expected 'an Adjustment layer', got: {err_equalize}");
|
||||
assert!(!err_equalize.contains("a Adjustment layer"), "found 'a Adjustment layer': {err_equalize}");
|
||||
|
||||
let err_clear = s.execute("edit.clear", json!({})).unwrap_err().to_string();
|
||||
assert!(err_clear.contains("an Adjustment layer"), "expected 'an Adjustment layer', got: {err_clear}");
|
||||
assert!(!err_clear.contains("a Adjustment layer"), "found 'a Adjustment layer': {err_clear}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_inspect_and_activate_invalid_index() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2, "height": 2, "name": "first"})).unwrap();
|
||||
s.execute("file.new", json!({"width": 2, "height": 2, "name": "second"})).unwrap();
|
||||
|
||||
assert!(s.execute("document.inspect", json!({})).is_ok());
|
||||
assert!(s.execute("document.inspect", json!({"document": 0})).is_ok());
|
||||
|
||||
let err = s.execute("document.inspect", json!({"document": 9})).unwrap_err();
|
||||
assert_eq!(err.to_string(), "no document at index 9");
|
||||
|
||||
let err_act = s.execute("document.activate", json!({"document": 9})).unwrap_err();
|
||||
assert_eq!(err_act.to_string(), "no document at index 9");
|
||||
}
|
||||
@@ -27,7 +27,10 @@ fn trap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bounds = doc.bounds();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("Trap needs a pixel layer (active layer is a {})", l.content.kind_name()) });
|
||||
return Err(EngineError::BadParams {
|
||||
cmd: cmd.into(),
|
||||
msg: format!("Trap needs a pixel layer (active layer is {} {})", l.content.article(), l.content.kind_name()),
|
||||
});
|
||||
};
|
||||
if bounds.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
@@ -69,7 +69,7 @@ struct Frame {
|
||||
fn frame(doc: &Document, id: LayerId, cmd: &str) -> Result<Frame> {
|
||||
let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Text(t) = &layer.content else {
|
||||
return Err(bad(cmd, format!("layer {} is a {} layer, not a type layer", id.0, layer.content.kind_name())));
|
||||
return Err(bad(cmd, format!("layer {} is {} {} layer, not a type layer", id.0, layer.content.article(), layer.content.kind_name())));
|
||||
};
|
||||
let text = t.text.clone();
|
||||
let transform = t.transform;
|
||||
|
||||
@@ -418,7 +418,7 @@ fn with_text_layer<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&m
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let auto_named = is_auto_named(l);
|
||||
let LayerContent::Text(t) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name())));
|
||||
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name())));
|
||||
};
|
||||
let before = t.cache.as_ref().map(|c| c.tile_bounds());
|
||||
let mut rename = None;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn with_text<R>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Tex
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let auto_named = is_auto_named(l);
|
||||
let LayerContent::Text(t) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("layer {} is a {} layer, not a type layer", id.0, l.content.kind_name())));
|
||||
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a type layer", id.0, l.content.article(), l.content.kind_name())));
|
||||
};
|
||||
let r = f(t, &snapshot)?;
|
||||
refresh(&snapshot, t);
|
||||
|
||||
@@ -503,7 +503,7 @@ fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Shape(sh) = &l.content else {
|
||||
return Err(EngineError::Other(format!("layer {} is a {} layer, not a shape layer", id.0, l.content.kind_name())));
|
||||
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name())));
|
||||
};
|
||||
let bounds = sh.cache.as_ref().map(|c| c.content_bounds()).filter(|r| !r.is_empty()).map(|r| json!([r.x0, r.y0, r.width(), r.height()]));
|
||||
Ok(json!({
|
||||
@@ -523,7 +523,7 @@ pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl F
|
||||
let snapshot = doc.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !matches!(l.content, LayerContent::Shape(_)) {
|
||||
return Err(EngineError::Other(format!("layer {} is a {} layer, not a shape layer", id.0, l.content.kind_name())));
|
||||
return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name())));
|
||||
}
|
||||
let LayerContent::Shape(mut sh) = std::mem::replace(&mut l.content, LayerContent::Fill(Fill::Solid(Color::BLACK))) else {
|
||||
return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0)));
|
||||
|
||||
@@ -69,7 +69,7 @@ fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) {
|
||||
Some(LayerContent::Raster(_)) => Ok(()),
|
||||
Some(other) => Err(format!("Vanishing Point needs a pixel layer (active layer is a {} layer)", other.kind_name())),
|
||||
Some(other) => Err(format!("Vanishing Point needs a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name())),
|
||||
None => Err("no active layer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ struct Job {
|
||||
opts: SaveOptions,
|
||||
}
|
||||
|
||||
/// One completed revision's disk-write outcome (distinct from enqueue success).
|
||||
type SaveOutcome = (u64, std::result::Result<(), String>);
|
||||
type CompletedWrites = Arc<Mutex<Vec<SaveOutcome>>>;
|
||||
|
||||
/// Background autosaver for one document. Requests are coalesced: if saves
|
||||
/// arrive faster than they complete, only the newest snapshot is written.
|
||||
pub struct Autosaver {
|
||||
@@ -56,6 +60,8 @@ pub struct Autosaver {
|
||||
tx: Option<Sender<Job>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
last: Arc<Mutex<Option<Result<SaveStats>>>>,
|
||||
/// Per-request write outcomes, delivered to the desktop without blocking its frame.
|
||||
completed: CompletedWrites,
|
||||
}
|
||||
|
||||
fn sanitize(key: &str) -> String {
|
||||
@@ -70,12 +76,14 @@ impl Autosaver {
|
||||
let key = sanitize(key);
|
||||
let (tx, rx) = mpsc::channel::<Job>();
|
||||
let last = Arc::new(Mutex::new(None));
|
||||
let completed = Arc::new(Mutex::new(Vec::new()));
|
||||
let bundle = dir.join(format!("{key}.pcraft"));
|
||||
let sidecar = dir.join(format!("{key}.json"));
|
||||
let last2 = last.clone();
|
||||
let completed2 = completed.clone();
|
||||
// A thread that never started must not look like a working autosave: record the
|
||||
// failure where `last_result`/`flush` report it, so recovery health checks see it.
|
||||
let spawned = std::thread::Builder::new().name(format!("autosave-{key}")).spawn(move || worker(rx, bundle, sidecar, last2));
|
||||
let spawned = std::thread::Builder::new().name(format!("autosave-{key}")).spawn(move || worker(rx, bundle, sidecar, last2, completed2));
|
||||
let handle = match spawned {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
@@ -83,15 +91,22 @@ impl Autosaver {
|
||||
None
|
||||
}
|
||||
};
|
||||
Autosaver { dir, key, tx: Some(tx), handle, last }
|
||||
Autosaver { dir, key, tx: Some(tx), handle, last, completed }
|
||||
}
|
||||
|
||||
pub fn bundle_path(&self) -> PathBuf {
|
||||
self.dir.join(format!("{}.pcraft", self.key))
|
||||
}
|
||||
|
||||
/// Queue a snapshot for saving (returns immediately).
|
||||
/// Queue a snapshot for saving (returns immediately). Existing callers that only
|
||||
/// need best-effort autosaving can use this; the app uses `request_checked`.
|
||||
pub fn request(&self, snapshot: Arc<Document>, revision: u64, original_path: Option<String>, opts: SaveOptions) {
|
||||
let _ = self.request_checked(snapshot, revision, original_path, opts);
|
||||
}
|
||||
|
||||
/// Queue a snapshot, reporting a worker that could not start or has stopped.
|
||||
/// Success here means queued, not written: use `take_completed` for write outcomes.
|
||||
pub fn request_checked(&self, snapshot: Arc<Document>, revision: u64, original_path: Option<String>, opts: SaveOptions) -> std::result::Result<(), String> {
|
||||
let info = RecoveryInfo {
|
||||
key: self.key.clone(),
|
||||
document_name: snapshot.name.clone(),
|
||||
@@ -99,9 +114,16 @@ impl Autosaver {
|
||||
saved_at: SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
|
||||
revision,
|
||||
};
|
||||
if let Some(tx) = &self.tx {
|
||||
let _ = tx.send(Job { snapshot, info, opts });
|
||||
}
|
||||
self.tx
|
||||
.as_ref()
|
||||
.ok_or_else(|| "autosave worker is stopped".to_string())?
|
||||
.send(Job { snapshot, info, opts })
|
||||
.map_err(|_| self.last_result().and_then(std::result::Result::err).unwrap_or_else(|| "autosave worker is unavailable".to_string()))
|
||||
}
|
||||
|
||||
/// Drain completed write results without waiting for the worker.
|
||||
pub fn take_completed(&self) -> Vec<(u64, std::result::Result<(), String>)> {
|
||||
self.completed.lock().map(|mut results| std::mem::take(&mut *results)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Result of the most recent completed save.
|
||||
@@ -136,7 +158,7 @@ impl Drop for Autosaver {
|
||||
}
|
||||
}
|
||||
|
||||
fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<Option<Result<SaveStats>>>>) {
|
||||
fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<Option<Result<SaveStats>>>>, completed: CompletedWrites) {
|
||||
let mut writer = PcraftWriter::new();
|
||||
while let Ok(mut job) = rx.recv() {
|
||||
// Coalesce: skip to the newest queued snapshot.
|
||||
@@ -149,6 +171,9 @@ fn worker(rx: Receiver<Job>, bundle: PathBuf, sidecar: PathBuf, last: Arc<Mutex<
|
||||
write_atomic(&sidecar, &serde_json::to_vec_pretty(&job.info)?)?;
|
||||
Ok(stats)
|
||||
})();
|
||||
if let Ok(mut results) = completed.lock() {
|
||||
results.push((job.info.revision, r.as_ref().map(|_| ()).map_err(ToString::to_string)));
|
||||
}
|
||||
if let Ok(mut g) = last.lock() {
|
||||
*g = Some(r);
|
||||
}
|
||||
@@ -231,12 +256,22 @@ impl RecoveryStore {
|
||||
|
||||
/// Queue an autosave of `doc` (returns immediately; see [`Autosaver::request`]).
|
||||
pub fn autosave(&mut self, doc: &Arc<Document>, revision: u64, original_path: Option<String>) {
|
||||
let _ = self.autosave_checked(doc, revision, original_path);
|
||||
}
|
||||
|
||||
/// Queue a snapshot, reporting enqueue failures to the application.
|
||||
pub fn autosave_checked(&mut self, doc: &Arc<Document>, revision: u64, original_path: Option<String>) -> std::result::Result<(), String> {
|
||||
let id = doc.id.0;
|
||||
let saver = self.savers.entry(id).or_insert_with(|| {
|
||||
let key = self.adopted.get(&id).cloned().unwrap_or_else(|| format!("doc-{}-{id}", self.session));
|
||||
Autosaver::new(&self.dir, &key)
|
||||
});
|
||||
saver.request(doc.clone(), revision, original_path, SaveOptions::default());
|
||||
saver.request_checked(doc.clone(), revision, original_path, SaveOptions::default())
|
||||
}
|
||||
|
||||
/// Return completed write outcomes as (document id, revision, outcome), without blocking.
|
||||
pub fn take_completed(&mut self) -> Vec<(u64, u64, std::result::Result<(), String>)> {
|
||||
self.savers.iter().flat_map(|(&id, saver)| saver.take_completed().into_iter().map(move |(revision, result)| (id, revision, result))).collect()
|
||||
}
|
||||
|
||||
/// Document `doc_id` was saved or closed: remove its recovery data, both its own autosaves
|
||||
|
||||
@@ -28,6 +28,42 @@ fn autosave_then_recover() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A queued snapshot can fail later; callers must observe and retry that revision.
|
||||
#[test]
|
||||
fn background_save_outcomes_report_failure_then_recovery() {
|
||||
let root = temp_dir("retry-failed-write");
|
||||
let blocked = root.join("recovery");
|
||||
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||
let saver = Autosaver::new(&blocked, "doc");
|
||||
let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8));
|
||||
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
|
||||
|
||||
let receive = |saver: &Autosaver| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Some(outcome) = saver.take_completed().into_iter().next() {
|
||||
return outcome;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("autosave worker did not report its completed write within 30 seconds");
|
||||
};
|
||||
let (revision, result) = receive(&saver);
|
||||
assert_eq!(revision, 7);
|
||||
assert!(result.is_err(), "the recovery location is not a directory");
|
||||
|
||||
std::fs::remove_file(&blocked).unwrap();
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
|
||||
let (revision, result) = receive(&saver);
|
||||
assert_eq!(revision, 7);
|
||||
result.unwrap();
|
||||
assert_eq!(list_recovery(&blocked).len(), 1);
|
||||
assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc);
|
||||
drop(saver);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_autosaves_are_incremental_and_coalesced() {
|
||||
let dir = temp_dir("coalesce");
|
||||
|
||||
@@ -220,7 +220,10 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result<Exp
|
||||
let mut img = Image::from_raw(w, h, layout, sample, data)?;
|
||||
img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec());
|
||||
img.meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
// EXIF is deliberately not set here: the TIFF codec has no EXIF directory, so the tags
|
||||
// above carry it as resource 1058 and the importer reads them back from there. Handing the
|
||||
// codec a second copy in `meta.exif` bought nothing and made the export claim it would
|
||||
// drop metadata it had in fact kept (#1545).
|
||||
xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
|
||||
@@ -107,6 +107,44 @@ fn every_compression_keeps_the_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Artist tag's text, and the bytes every EXIF assertion looks for.
|
||||
const ARTIST: &[u8] = b"fixture-artist";
|
||||
|
||||
/// A minimal little-endian EXIF (TIFF) block with a single Artist tag and no Orientation, so
|
||||
/// `upright_exif` passes it through untouched.
|
||||
fn exif() -> Vec<u8> {
|
||||
let mut artist = ARTIST.to_vec();
|
||||
artist.push(0); // ASCII NUL terminator
|
||||
let mut v = b"II*\0\x08\0\0\0".to_vec(); // TIFF header, first IFD at 8
|
||||
v.extend_from_slice(&1u16.to_le_bytes()); // one entry
|
||||
v.extend_from_slice(&0x013bu16.to_le_bytes()); // Artist
|
||||
v.extend_from_slice(&2u16.to_le_bytes()); // ASCII
|
||||
v.extend_from_slice(&(artist.len() as u32).to_le_bytes());
|
||||
v.extend_from_slice(&26u32.to_le_bytes()); // value offset: 8 + 2 + 12 + 4
|
||||
v.extend_from_slice(&0u32.to_le_bytes()); // no next IFD
|
||||
v.extend_from_slice(&artist);
|
||||
v
|
||||
}
|
||||
|
||||
/// #1545: a layered TIFF carries EXIF as Photoshop resource 1058 and reads it back, so it must
|
||||
/// not report a loss it never makes. A flat TIFF really does lose the tags and still says so.
|
||||
#[test]
|
||||
fn layered_tiff_keeps_exif_without_claiming_a_loss() {
|
||||
let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
d.metadata.exif = Some(std::sync::Arc::new(exif()));
|
||||
|
||||
let r = tiff(&d, true);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("EXIF")), "a layered TIFF keeps EXIF, so it must not claim to drop it: {:?}", r.warnings);
|
||||
// ...and the tags really are in the file, so the missing warning is a true absence of loss.
|
||||
let back = import("x.tif", &r.bytes).unwrap().document;
|
||||
let kept = back.metadata.exif.as_deref().expect("EXIF survives the layered export");
|
||||
assert!(kept.windows(ARTIST.len()).any(|w| w == ARTIST), "the Artist tag did not survive: {kept:?}");
|
||||
|
||||
// The control from the issue: the flat path drops it for real, and keeps reporting that.
|
||||
let flat = tiff(&d, false);
|
||||
assert!(flat.warnings.iter().any(|w| w.contains("EXIF")), "a flat TIFF really does lose EXIF: {:?}", flat.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_layers_writes_a_flat_file() {
|
||||
let d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
|
||||
|
||||
+19
-8
@@ -147,17 +147,28 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
|
||||
}
|
||||
let cfa = if is_cfa { Some(cfa(t, &raw, &active)?) } else { None };
|
||||
|
||||
// Default crop, relative to the active area.
|
||||
// Default crop, relative to the active area. Each tag has its own default (DNG 1.7.1.0,
|
||||
// DefaultCropOrigin and DefaultCropSize): the origin (0, 0), the size the whole image.
|
||||
let mut crop = active;
|
||||
let origin = t.tag_floats(&raw, tag::DEFAULT_CROP_ORIGIN);
|
||||
let size = t.tag_floats(&raw, tag::DEFAULT_CROP_SIZE);
|
||||
if raw.has(tag::DEFAULT_CROP_ORIGIN) || raw.has(tag::DEFAULT_CROP_SIZE) {
|
||||
if let ([ox, oy], [cw, ch]) = (origin.as_slice(), size.as_slice()) {
|
||||
// A present tag must hold two values; an absent one takes its default.
|
||||
let pair = |tg: u16, default: [f64; 2]| -> Option<[f64; 2]> {
|
||||
if !raw.has(tg) {
|
||||
return Some(default);
|
||||
}
|
||||
match t.tag_floats(&raw, tg).as_slice() {
|
||||
[a, b] => Some([*a, *b]),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let origin = pair(tag::DEFAULT_CROP_ORIGIN, [0.0, 0.0]);
|
||||
let size = pair(tag::DEFAULT_CROP_SIZE, [plane.width as f64, plane.height as f64]);
|
||||
if let (Some([ox, oy]), Some([cw, ch])) = (origin, size) {
|
||||
let (ox, oy, cw, ch) = (
|
||||
crop_value(*ox, "DefaultCropOrigin"),
|
||||
crop_value(*oy, "DefaultCropOrigin"),
|
||||
crop_value(*cw, "DefaultCropSize"),
|
||||
crop_value(*ch, "DefaultCropSize"),
|
||||
crop_value(ox, "DefaultCropOrigin"),
|
||||
crop_value(oy, "DefaultCropOrigin"),
|
||||
crop_value(cw, "DefaultCropSize"),
|
||||
crop_value(ch, "DefaultCropSize"),
|
||||
);
|
||||
let (ox, oy, cw, ch) = (ox?, oy?, cw?, ch?);
|
||||
let active_right = active.x.checked_add(active.width).ok_or_else(|| RawError::malformed("DNG active-area right edge overflow"))?;
|
||||
|
||||
@@ -396,6 +396,8 @@ pub struct DngSpec {
|
||||
pub default_crop: Option<([u32; 2], [u32; 2])>,
|
||||
/// Double-precision origin override for malformed-metadata regression tests.
|
||||
pub default_crop_origin_double: Option<[f64; 2]>,
|
||||
/// A DefaultCropSize with no DefaultCropOrigin (`default_crop` must be `None`).
|
||||
pub default_crop_size_only: Option<[u32; 2]>,
|
||||
pub linearization: Option<Vec<u16>>,
|
||||
/// (illuminant code, ColorMatrix row-major)
|
||||
pub color_matrix1: Option<(u16, [f64; 9])>,
|
||||
@@ -429,6 +431,7 @@ impl DngSpec {
|
||||
active_area: None,
|
||||
default_crop: None,
|
||||
default_crop_origin_double: None,
|
||||
default_crop_size_only: None,
|
||||
linearization: None,
|
||||
color_matrix1: None,
|
||||
color_matrix2: None,
|
||||
@@ -564,6 +567,8 @@ impl DngSpec {
|
||||
raw.push((50720, Val::Long(s.to_vec())));
|
||||
} else if let Some(origin) = self.default_crop_origin_double {
|
||||
raw.push((50719, Val::Double(origin.to_vec())));
|
||||
} else if let Some(size) = self.default_crop_size_only {
|
||||
raw.push((50720, Val::Long(size.to_vec())));
|
||||
}
|
||||
if let Some(o) = &self.opcode_list2 {
|
||||
raw.push((51009, Val::Undefined(o.clone())));
|
||||
|
||||
+28
-2
@@ -128,8 +128,9 @@ fn out_of_range_default_crop_falls_back_to_active_area_with_warning() {
|
||||
assert!(s.warnings.iter().any(|warning| warning.contains("out-of-range") && warning.contains("default crop")), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// DefaultCropSize defaults to the whole image, which can't fit once the origin moves in.
|
||||
#[test]
|
||||
fn incomplete_default_crop_falls_back_to_active_area_with_warning() {
|
||||
fn default_crop_origin_without_size_falls_back_to_active_area_with_warning() {
|
||||
let (w, h) = (16, 12);
|
||||
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
|
||||
spec.active_area = Some([2, 3, 10, 14]);
|
||||
@@ -137,7 +138,32 @@ fn incomplete_default_crop_falls_back_to_active_area_with_warning() {
|
||||
|
||||
let s = sensor(&spec.build());
|
||||
assert_eq!(s.crop, s.active);
|
||||
assert!(s.warnings.iter().any(|warning| warning.contains("incomplete") && warning.contains("default crop")), "{:?}", s.warnings);
|
||||
assert!(s.warnings.iter().any(|warning| warning.contains("out-of-range") && warning.contains("default crop")), "{:?}", s.warnings);
|
||||
}
|
||||
|
||||
/// #950: DefaultCropOrigin defaults to (0, 0), so a size-only crop starts at the active area's
|
||||
/// top-left corner.
|
||||
#[test]
|
||||
fn default_crop_size_without_origin_crops_from_the_active_area_origin() {
|
||||
let (w, h) = (8, 8);
|
||||
let mut spec = DngSpec::cfa(w, h, (0..w * h).map(|i| (i as u16) * 37).collect());
|
||||
spec.active_area = Some([0, 0, 8, 8]);
|
||||
spec.default_crop_size_only = Some([4, 4]);
|
||||
let bytes = spec.build();
|
||||
|
||||
let s = sensor(&bytes);
|
||||
assert_eq!(s.crop, Rect::new(0, 0, 4, 4));
|
||||
assert!(s.warnings.iter().all(|warning| !warning.contains("default crop")), "{:?}", s.warnings);
|
||||
let d = develop(&bytes, &DevelopOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (4, 4));
|
||||
|
||||
// Inside an offset active area the crop starts at that area's corner.
|
||||
let (w, h) = (16, 12);
|
||||
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
|
||||
spec.active_area = Some([2, 3, 10, 14]);
|
||||
spec.default_crop_size_only = Some([6, 4]);
|
||||
let s = sensor(&spec.build());
|
||||
assert_eq!(s.crop, Rect::new(3, 2, 6, 4));
|
||||
}
|
||||
|
||||
/// Linear sRGB (D65) → XYZ, from the sRGB primaries (IEC 61966-2-1).
|
||||
|
||||
@@ -195,6 +195,17 @@ pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -
|
||||
([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy])
|
||||
}
|
||||
|
||||
/// The pixel rectangle a Rectangular/Elliptical Marquee drag selects so far: shown live (ants
|
||||
/// and readout) exactly as the release commits it, on whole pixel edges as in Photoshop.
|
||||
pub(crate) fn marquee_preview_px(o: &crate::state::ToolOptions, d: &Drag) -> Option<[f64; 4]> {
|
||||
if !matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) {
|
||||
return None;
|
||||
}
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let (a, b) = marquee_corners(o, d, last);
|
||||
Some(marquee_px(a, b))
|
||||
}
|
||||
|
||||
/// The pixel rectangle `[x0, y0, x1, y1]` a marquee between two corners selects.
|
||||
pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
|
||||
[a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
|
||||
@@ -3023,9 +3034,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
|
||||
return;
|
||||
}
|
||||
let last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
|
||||
let marquee = matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee).then(|| marquee_corners(&app.ui.tool_options, d, last));
|
||||
if let Some((a, b)) = marquee {
|
||||
draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(marquee_px(a, b)));
|
||||
let marquee = marquee_preview_px(&app.ui.tool_options, d);
|
||||
if let Some(r) = marquee {
|
||||
draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(r));
|
||||
}
|
||||
match d.tool {
|
||||
// The canvas shows the live stroke itself (`LiveStroke`); a stroke that couldn't start one
|
||||
@@ -3045,7 +3056,7 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
|
||||
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.live),
|
||||
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
|
||||
// Marching ants, visible on any pixels (#172).
|
||||
let (a, b) = marquee.unwrap_or((d.start, last));
|
||||
let (a, b) = marquee.map_or((d.start, last), |[x0, y0, x1, y1]| ([x0, y0], [x1, y1]));
|
||||
let r = Rect::from_two_pos(xf.to_screen(a[0] as f32, a[1] as f32), xf.to_screen(b[0] as f32, b[1] as f32));
|
||||
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
|
||||
let pts = if d.tool == Tool::EllipseMarquee {
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn active_pixels(app: &PhotocraftApp) -> Result<(LayerId, Surface, bo
|
||||
match &l.content {
|
||||
LayerContent::Raster(s) => Ok((id, s.clone(), false)),
|
||||
LayerContent::Smart(sm) => sm.cache.clone().map(|c| (id, c, true)).ok_or_else(|| "the smart object has no pixels".to_string()),
|
||||
other => Err(format!("needs a pixel layer or smart object (active layer is a {} layer)", other.kind_name())),
|
||||
other => Err(format!("needs a pixel layer or smart object (active layer is {} {} layer)", other.article(), other.kind_name())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3350,6 +3350,7 @@ fontWeight Thin Πολύ λεπτά
|
||||
|
||||
# Screen color picker (#1232)
|
||||
Pick screen color Επιλογή χρώματος οθόνης
|
||||
Pick a screen pixel or press Esc to cancel Επιλέξτε ένα εικονοστοιχείο της οθόνης ή πατήστε Esc για ακύρωση
|
||||
Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη
|
||||
Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση
|
||||
Scroll to zoom Κύλιση για μεγέθυνση
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 960 and column 18.
|
@@ -488,10 +488,15 @@ fn eyedropper_and_alt_sampling_show_a_pipette() {
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
|
||||
assert_eq!(cursor(&mut h), egui::CursorIcon::None, "⌥ samples with a pipette");
|
||||
h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap();
|
||||
// Windows draws the crosshair on the canvas and hides the OS cursor (`visible_crosshair`).
|
||||
let crosshair = if cfg!(target_os = "windows") { egui::CursorIcon::None } else { egui::CursorIcon::Crosshair };
|
||||
assert_eq!(cursor(&mut h), crosshair, "Precise keeps the crosshair");
|
||||
// Every platform reports the crosshair. Windows also hands the OS a black-and-white bitmap
|
||||
// for it (`tool_cursor`, #1160), with `Crosshair` underneath as the fallback.
|
||||
let precise = |h: &mut Harness<'static, PhotocraftApp>| {
|
||||
let icon = cursor(h);
|
||||
(icon, h.output().platform_output.cursor_image.is_some())
|
||||
};
|
||||
let windows = cfg!(target_os = "windows");
|
||||
assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, windows), "Precise keeps the crosshair");
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.state_mut().ui.tool = crate::state::Tool::Eyedropper;
|
||||
assert_eq!(cursor(&mut h), crosshair);
|
||||
assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, windows));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The Layers panel's Opacity and Fill fields open Photoshop's pop-up slider from their ▾; a drag
|
||||
//! on it, a press-and-drag from the ▾ and a scrub of the number are each one history step.
|
||||
|
||||
use egui::{Pos2, Rect, accesskit::Role, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
fn harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 120})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 1000.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
fn layer(h: &Harness<'_, PhotocraftApp>) -> photocraft_doc::Layer {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().clone()
|
||||
}
|
||||
|
||||
fn steps(h: &Harness<'_, PhotocraftApp>) -> usize {
|
||||
h.state().session.active().unwrap().history.entries().len()
|
||||
}
|
||||
|
||||
fn by_role(h: &Harness<'_, PhotocraftApp>, name: &str, role: Role) -> Option<Rect> {
|
||||
h.query_all_by_role(role).find(|n| n.accesskit_node().label().as_deref() == Some(name)).map(|n| n.rect())
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2) {
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.hover_at(from + (to - from) * (i as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_and_fill_sliders_pop_up_and_drag_in_one_step() {
|
||||
for (name, value) in [("Opacity", (|l: &photocraft_doc::Layer| l.opacity) as fn(&photocraft_doc::Layer) -> f32), ("Fill", |l| l.fill_opacity)] {
|
||||
let mut h = harness();
|
||||
assert!(by_role(&h, name, Role::Slider).is_none(), "{name}: closed until its ▾ is clicked");
|
||||
let arrow = by_role(&h, name, Role::Button).unwrap_or_else(|| panic!("{name}: no ▾"));
|
||||
click(&mut h, arrow.center());
|
||||
let slider = by_role(&h, name, Role::Slider).unwrap_or_else(|| panic!("{name}: the slider didn't open"));
|
||||
assert!(slider.top() >= arrow.bottom() || slider.bottom() <= arrow.top(), "{name}: the slider covers its field");
|
||||
|
||||
// A drag from the right end towards the left: live, and one undoable step.
|
||||
let before = steps(&h);
|
||||
// The knob at 100% sits at the track's right end, 7 pt in from the slider's.
|
||||
drag(&mut h, slider.right_center() - vec2(7.0, 0.0), slider.center());
|
||||
let v = value(&layer(&h));
|
||||
assert!((0.4..0.6).contains(&v), "{name}: dragged to the middle, got {v}");
|
||||
assert_eq!(steps(&h), before + 1, "{name}: a drag is one history step");
|
||||
h.state_mut().session.undo();
|
||||
h.run_steps(3);
|
||||
assert!((value(&layer(&h)) - 1.0).abs() < 1e-6, "{name}: undo restores 100%");
|
||||
|
||||
// A click outside closes it.
|
||||
click(&mut h, Pos2::new(700.0, 500.0));
|
||||
assert!(by_role(&h, name, Role::Slider).is_none(), "{name}: still open after a click outside");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_the_arrow_and_dragging_sets_the_value_and_closes_on_release() {
|
||||
for (name, value) in [("Opacity", (|l: &photocraft_doc::Layer| l.opacity) as fn(&photocraft_doc::Layer) -> f32), ("Fill", |l| l.fill_opacity)] {
|
||||
let mut h = harness();
|
||||
let arrow = by_role(&h, name, Role::Button).unwrap().center();
|
||||
let before = steps(&h);
|
||||
h.hover_at(arrow);
|
||||
h.run_steps(1);
|
||||
h.drag_at(arrow);
|
||||
h.run_steps(1);
|
||||
// Half the slider's track to the left: 100% → 50%.
|
||||
let to = arrow - vec2(73.0, 0.0);
|
||||
for i in 1..=6 {
|
||||
h.hover_at(arrow + (to - arrow) * (i as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert!(by_role(&h, name, Role::Slider).is_some(), "{name}: the slider shows while the ▾ is dragged");
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
let v = value(&layer(&h));
|
||||
assert!((v - 0.5).abs() < 0.02, "{name}: got {v}");
|
||||
assert_eq!(steps(&h), before + 1, "{name}: a press-and-drag is one history step");
|
||||
assert!(by_role(&h, name, Role::Slider).is_none(), "{name}: release closes the slider");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_the_number_is_one_history_step() {
|
||||
let mut h = harness();
|
||||
let arrow = by_role(&h, "Opacity", Role::Button).unwrap();
|
||||
// The number is the spin button just left of the ▾.
|
||||
let field = h
|
||||
.query_all_by_role(Role::SpinButton)
|
||||
.map(|n| n.rect())
|
||||
.find(|r| r.y_range().contains(arrow.center().y) && r.right() <= arrow.left() + 1.0 && r.right() >= arrow.left() - 30.0)
|
||||
.expect("the Opacity number");
|
||||
let before = steps(&h);
|
||||
drag(&mut h, field.center(), field.center() - vec2(40.0, 0.0));
|
||||
let v = layer(&h).opacity;
|
||||
assert!(v < 0.95, "scrubbed down, got {v}");
|
||||
assert_eq!(steps(&h), before + 1, "a scrub is one history step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_layer_has_no_pop_up_slider() {
|
||||
let mut h = harness();
|
||||
let bg = h.state().session.active().unwrap().doc.layers[0].id.0;
|
||||
h.state_mut().run("layer.select", json!({"layer": bg})).unwrap();
|
||||
h.run_steps(8);
|
||||
let arrow = by_role(&h, "Opacity", Role::Button).unwrap();
|
||||
click(&mut h, arrow.center());
|
||||
assert!(by_role(&h, "Opacity", Role::Slider).is_none(), "Photoshop greys Opacity for the Background");
|
||||
}
|
||||
@@ -210,6 +210,8 @@ pub type LoadTextFn = Box<dyn FnMut() -> Option<String>>;
|
||||
pub type SaveTextFn = Box<dyn FnMut(&str) -> Result<(), String>>;
|
||||
/// Autosave a document snapshot for crash recovery: (snapshot, revision, original path).
|
||||
pub type AutosaveFn = Box<dyn FnMut(&std::sync::Arc<Document>, u64, Option<&str>) -> Result<(), String>>;
|
||||
/// Poll successful or failed background writes: (document id, revision, result).
|
||||
pub type AutosaveResultsFn = Box<dyn FnMut() -> Vec<(u64, u64, Result<(), String>)>>;
|
||||
/// Drop the recovery data of a document (by `DocId` value) once it is saved or closed.
|
||||
pub type DiscardAutosaveFn = Box<dyn FnMut(u64)>;
|
||||
/// Load recoverable documents left by a previous session. Their recovery data stays until the
|
||||
@@ -277,6 +279,8 @@ pub struct Services {
|
||||
pub xwayland_command: Option<String>,
|
||||
/// Crash-recovery autosave (Preferences › File Handling) and recovery at launch.
|
||||
pub autosave: Option<AutosaveFn>,
|
||||
/// None for services that report synchronous success through `autosave`.
|
||||
pub autosave_results: Option<AutosaveResultsFn>,
|
||||
pub discard_autosave: Option<DiscardAutosaveFn>,
|
||||
pub recover: Option<RecoverFn>,
|
||||
pub adopt_autosave: Option<AdoptAutosaveFn>,
|
||||
|
||||
@@ -134,6 +134,25 @@ fn alt_draws_from_the_centre_and_shift_alt_a_centred_circle() {
|
||||
assert!(r.x0.abs_diff(170) <= 1 && r.x1.abs_diff(230) <= 1 && r.y0.abs_diff(120) <= 1 && r.y1.abs_diff(180) <= 1, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_live_outline_snaps_to_pixels_and_matches_the_committed_selection() {
|
||||
for tool in [Tool::RectMarquee, Tool::EllipseMarquee] {
|
||||
let mut h = harness(tool);
|
||||
// Zoomed in, the pointer lands between pixel edges.
|
||||
let v = &mut h.state_mut().ui.views[0];
|
||||
v.zoom = 8.0;
|
||||
v.center = [20.0, 15.0];
|
||||
h.run_steps(2);
|
||||
press_at(&mut h, 10.3, 10.3, Modifiers::NONE);
|
||||
move_to(&mut h, 20.6, 15.4);
|
||||
let app = h.state();
|
||||
let live = app.drag.as_ref().and_then(|d| crate::canvas::marquee_preview_px(&app.ui.tool_options, d)).unwrap();
|
||||
assert_eq!(live, [10.0, 10.0, 21.0, 16.0], "{tool:?}: whole pixels while dragging");
|
||||
release_at(&mut h, 20.6, 15.4, Modifiers::NONE);
|
||||
assert_eq!(selection(&h), Rect::new(10, 10, 21, 16), "{tool:?}: the commit is what was shown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() {
|
||||
// #188: ⇧ held at the press adds (no square); released and pressed again it constrains.
|
||||
|
||||
@@ -971,6 +971,19 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Width of the Layers panel's Opacity and Fill fields, with room for their pop-up slider's ▾.
|
||||
const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W;
|
||||
|
||||
/// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A
|
||||
/// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop.
|
||||
fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option<u64>) -> (String, Value) {
|
||||
let mut p = json!({"layer": l.id.0, key: pct / 100.0});
|
||||
if let Some(n) = drag {
|
||||
p["coalesce"] = json!(format!("layer-{key}-slider:{n}"));
|
||||
}
|
||||
("layer.setProps".into(), p)
|
||||
}
|
||||
|
||||
fn label(ui: &mut egui::Ui, s: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(RichText::new(s).color(t.text_dim));
|
||||
@@ -1430,7 +1443,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// Leave room for the Opacity label and field: a translated label ("Непрозрачность:")
|
||||
// can be much wider than the English one, and must not slide under the dropdown.
|
||||
let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") };
|
||||
let right = (body_text_width(ui, opacity_label) + 66.0 + 2.0 * ui.spacing().item_spacing.x + 16.0).max(150.0);
|
||||
let right = (body_text_width(ui, opacity_label) + LAYER_PCT_W + 2.0 * ui.spacing().item_spacing.x + 16.0).max(150.0);
|
||||
let w = ui.available_width() - right;
|
||||
let (chosen, hovered) = widgets::dropdown_hovered(ui, "blend", &mut m, &blend_options(l.is_group()), w.max(100.0));
|
||||
if chosen {
|
||||
@@ -1440,8 +1453,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::blend_preview::hover(app, l.id, hovered.filter(|_| !chosen));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut o = l.opacity * 100.0;
|
||||
if widgets::value_field(ui, &mut o, 0.0..=100.0, "%", 66.0).changed() {
|
||||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "opacity": o / 100.0})));
|
||||
let r = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
if r.changed {
|
||||
actions.push(pct_action(l, "opacity", o, r.drag));
|
||||
}
|
||||
label(ui, opacity_label);
|
||||
});
|
||||
@@ -1456,7 +1470,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// their tooltips) rather than let the Fill label run over the lock icons.
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let icons_w = if t.pro { 5.0 * 20.0 } else { 22.0 + gap };
|
||||
let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + 66.0 + 2.0 * gap <= ui.available_width();
|
||||
let fits = body_text_width(ui, lock_label) + icons_w + body_text_width(ui, fill_label) + LAYER_PCT_W + 2.0 * gap <= ui.available_width();
|
||||
if fits {
|
||||
label(ui, lock_label);
|
||||
}
|
||||
@@ -1484,8 +1498,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.add_enabled_ui(!bg, |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut f = l.fill_opacity * 100.0;
|
||||
if widgets::value_field(ui, &mut f, 0.0..=100.0, "%", 66.0).changed() {
|
||||
actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "fill": f / 100.0})));
|
||||
let r = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
if r.changed {
|
||||
actions.push(pct_action(l, "fill", f, r.drag));
|
||||
}
|
||||
label(ui, fill_label);
|
||||
})
|
||||
@@ -3061,6 +3076,10 @@ mod history_transform_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layer_pct_slider_tests.rs"]
|
||||
mod layer_pct_slider_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -30,7 +30,10 @@ pub struct Runtime {
|
||||
save_retry: SaveRetry,
|
||||
theme_pref: Option<Theme>,
|
||||
next_autosave_ms: f64,
|
||||
/// Last revision whose recovery file was actually written successfully.
|
||||
autosaved: HashMap<DocId, u64>,
|
||||
/// Queued writes must not masquerade as durable snapshots.
|
||||
autosave_pending: HashMap<DocId, u64>,
|
||||
log_len: usize,
|
||||
/// Snapping state of the drag in progress (see `snap_ui`).
|
||||
pub(crate) snap: Option<crate::snap_ui::ActiveSnap>,
|
||||
@@ -368,12 +371,34 @@ fn autosave(app: &mut PhotocraftApp) {
|
||||
if app.services.autosave.is_none() {
|
||||
return;
|
||||
}
|
||||
// A successful queue operation is not a successful disk write. Poll each frame,
|
||||
// including frames before the next autosave interval, so failures are visible.
|
||||
if let Some(results) = app.services.autosave_results.as_mut() {
|
||||
for (raw_id, revision, result) in results() {
|
||||
let id = DocId(raw_id);
|
||||
if app.prefs_rt.autosave_pending.get(&id) != Some(&revision) {
|
||||
continue;
|
||||
}
|
||||
app.prefs_rt.autosave_pending.remove(&id);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
app.prefs_rt.autosaved.insert(id, revision);
|
||||
}
|
||||
Err(e) => {
|
||||
app.ui.status = format!("Autosave failed: {e}");
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = crate::gpu_canvas::now_ms();
|
||||
// Saved or closed documents drop their recovery data.
|
||||
let live: HashMap<DocId, bool> = app.session.documents().iter().map(|d| (d.doc.id, d.is_dirty())).collect();
|
||||
let stale: Vec<DocId> = app.prefs_rt.autosaved.keys().filter(|id| live.get(id) != Some(&true)).copied().collect();
|
||||
let mut stale: Vec<DocId> = app.prefs_rt.autosaved.keys().filter(|id| live.get(id) != Some(&true)).copied().collect();
|
||||
stale.extend(app.prefs_rt.autosave_pending.keys().filter(|id| live.get(*id) != Some(&true) && !app.prefs_rt.autosaved.contains_key(*id)).copied());
|
||||
for id in stale {
|
||||
app.prefs_rt.autosaved.remove(&id);
|
||||
app.prefs_rt.autosave_pending.remove(&id);
|
||||
if let Some(d) = app.services.discard_autosave.as_mut() {
|
||||
d(id.0);
|
||||
}
|
||||
@@ -394,16 +419,24 @@ fn autosave(app: &mut PhotocraftApp) {
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.filter(|d| d.is_dirty() && app.prefs_rt.autosaved.get(&d.doc.id) != Some(&d.revision))
|
||||
.filter(|d| d.is_dirty() && app.prefs_rt.autosaved.get(&d.doc.id) != Some(&d.revision) && !app.prefs_rt.autosave_pending.contains_key(&d.doc.id))
|
||||
.map(|d| (d.doc.clone(), d.revision, d.path.clone()))
|
||||
.collect();
|
||||
for (doc, rev, path) in jobs {
|
||||
if let Some(save) = app.services.autosave.as_mut() {
|
||||
match save(&doc, rev, path.as_deref()) {
|
||||
Ok(()) => {
|
||||
app.prefs_rt.autosaved.insert(doc.id, rev);
|
||||
if app.services.autosave_results.is_some() {
|
||||
app.prefs_rt.autosave_pending.insert(doc.id, rev);
|
||||
} else {
|
||||
// Synchronous or mocked services report completion on return.
|
||||
app.prefs_rt.autosaved.insert(doc.id, rev);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.ui.status = format!("Autosave failed: {e}");
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
Err(e) => app.ui.status = format!("Autosave failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,8 @@ fn wraps(id: &str) -> bool {
|
||||
| "view.newGuideLayout"
|
||||
| "type.warpText"
|
||||
| "type.pasteLoremIpsum"
|
||||
| "image.applyImage"
|
||||
| "image.calculations"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,18 @@ fn engine_commands_open_their_dialogs() {
|
||||
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_image_and_calculations_open_dialogs_without_editing() {
|
||||
for id in ["image.applyImage", "image.calculations"] {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let dialog = menu(&mut app, &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap_or_else(|| panic!("{id} did not open a dialog"));
|
||||
let fields = &app.ui.dialog_mut(dialog).unwrap().fields;
|
||||
assert_eq!(fields["__command"], id, "{id} opened the wrong dialog");
|
||||
assert_eq!(app.session.active().unwrap().revision, revision, "{id} edited before confirmation");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_guide_layout_remembers_successful_values_not_cancelled_edits() {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
|
||||
@@ -143,6 +143,12 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
|
||||
|
||||
/// Monospace numeric field with a dimmed unit suffix, Photoshop style. Drag to scrub.
|
||||
pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32) -> Response {
|
||||
value_field_in(ui, value, range, suffix, width, 0.0).0
|
||||
}
|
||||
|
||||
/// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and
|
||||
/// returns the box with the number's response.
|
||||
fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32, trailing: f32) -> (Response, Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, slot) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover());
|
||||
surface(ui, rect, t.field, false);
|
||||
@@ -150,7 +156,7 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
let suffix_w = if suffix.is_empty() { 0.0 } else { 16.0 };
|
||||
let field = Rect::from_min_max(rect.min + vec2(4.0, 2.0), rect.max - vec2(4.0 + suffix_w, 2.0));
|
||||
let field = Rect::from_min_max(rect.min + vec2(4.0, 2.0), rect.max - vec2(4.0 + suffix_w + trailing, 2.0));
|
||||
// Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop.
|
||||
let fine = range.end() - range.start() <= 10.0;
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
@@ -175,7 +181,7 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(slot.id, resp.id));
|
||||
if !suffix.is_empty() {
|
||||
ui.painter().text(pos2(rect.right() - 6.0, rect.center().y), Align2::RIGHT_CENTER, suffix, theme::mono(11.0), t.text_faint);
|
||||
ui.painter().text(pos2(rect.right() - 6.0 - trailing, rect.center().y), Align2::RIGHT_CENTER, suffix, theme::mono(11.0), t.text_faint);
|
||||
}
|
||||
if step != 0.0 {
|
||||
// Round to the step before adding it, so whole-number fields drop decimals (55.4 + 1 = 56).
|
||||
@@ -187,7 +193,104 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive
|
||||
ui.memory_mut(|m| m.request_focus(resp.id));
|
||||
resp.mark_changed();
|
||||
}
|
||||
resp
|
||||
(resp, rect)
|
||||
}
|
||||
|
||||
/// Width of the ▾ that opens a [`popup_value_field`]'s slider, inside the field's box.
|
||||
pub const POPUP_ARROW_W: f32 = 14.0;
|
||||
|
||||
/// Width of a [`popup_value_field`]'s pop-up slider.
|
||||
const POPUP_SLIDER_W: f32 = 160.0;
|
||||
|
||||
/// What a [`popup_value_field`] did this frame.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PopupFieldResponse {
|
||||
/// The value changed (typed, scrubbed, stepped with the arrow keys, or set on the slider).
|
||||
pub changed: bool,
|
||||
/// While the value is dragged (on the slider, from the ▾, or scrubbing the number): a number
|
||||
/// for that drag, the same on every frame of it, for the caller to coalesce the drag's edits
|
||||
/// into one history step.
|
||||
pub drag: Option<u64>,
|
||||
}
|
||||
|
||||
/// A [`value_field`] with Photoshop's pop-up slider (the Layers panel's Opacity and Fill): a click
|
||||
/// on the ▾ inside the field's right edge opens a slider below it, a click outside closes it.
|
||||
/// Pressing the ▾ and dragging moves the value straight away, as far as the slider would, and
|
||||
/// closes the slider on release. The number can still be typed, scrubbed and stepped like any
|
||||
/// value field. `name` (already translated) names the ▾ for its tooltip and for accessibility.
|
||||
pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32) -> PopupFieldResponse {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W);
|
||||
let arrow = Rect::from_min_max(pos2(rect.right() - POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - vec2(2.0, 2.0));
|
||||
let resp = ui.interact(arrow, field.id.with("popup-slider"), Sense::click_and_drag());
|
||||
let name = name.trim_end_matches([':', ':']).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &name));
|
||||
let resp = resp.on_hover_text(&name);
|
||||
let drag_key = resp.id.with("drag");
|
||||
// Every drag gets a new number when it starts, even if its first frame changes nothing.
|
||||
let gesture = |ui: &Ui, started: bool| {
|
||||
if started {
|
||||
let n = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(drag_key, n));
|
||||
}
|
||||
ui.data(|d| d.get_temp::<u64>(drag_key))
|
||||
};
|
||||
let mut out = PopupFieldResponse { changed: field.changed(), drag: None };
|
||||
let scrub = gesture(ui, field.drag_started());
|
||||
if field.changed() && field.dragged() {
|
||||
out.drag = scrub;
|
||||
}
|
||||
|
||||
let popup_id = egui::Popup::default_response_id(&resp);
|
||||
let start_key = resp.id.with("start");
|
||||
if resp.drag_started() {
|
||||
egui::Popup::open_id(ui.ctx(), popup_id);
|
||||
ui.data_mut(|d| d.insert_temp(start_key, *value));
|
||||
gesture(ui, true);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(start), Some(origin), Some(pos)) =
|
||||
(ui.data(|d| d.get_temp::<f32>(start_key)), ui.input(|i| i.pointer.press_origin()), resp.interact_pointer_pos())
|
||||
{
|
||||
// The pointer moves the value as much as it would move the slider's knob.
|
||||
let track = POPUP_SLIDER_W - 14.0;
|
||||
let v = start + (pos.x - origin.x) / track * (hi - lo);
|
||||
let v = if lo <= hi && v.is_finite() { v.clamp(lo, hi) } else { start };
|
||||
if v != *value {
|
||||
*value = v;
|
||||
out.changed = true;
|
||||
}
|
||||
out.drag = gesture(ui, false);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
egui::Popup::close_id(ui.ctx(), popup_id);
|
||||
}
|
||||
|
||||
let popup = egui::Popup::from_toggle_button_response(&resp)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.anchor(rect)
|
||||
.align(egui::RectAlign::BOTTOM_END)
|
||||
.align_alternatives(&[egui::RectAlign::TOP_END]);
|
||||
let open = popup.is_open();
|
||||
if resp.hovered() || resp.dragged() || open {
|
||||
ui.painter().rect_filled(arrow, t.radius_sm, t.field_border.gamma_multiply(0.6));
|
||||
}
|
||||
chevron_icon(ui, arrow, ui.style().interact(&resp), open);
|
||||
popup.show(|ui| {
|
||||
ui.set_width(POPUP_SLIDER_W);
|
||||
let s = slider(ui, value, range, None);
|
||||
s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), &name));
|
||||
let key = gesture(ui, s.drag_started());
|
||||
if s.changed() {
|
||||
out.changed = true;
|
||||
// A click on the track is one edit of its own; a drag is one edit however long.
|
||||
if s.dragged() {
|
||||
out.drag = key;
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for
|
||||
|
||||
@@ -275,7 +275,7 @@ no MCP framing, no app start-up per command. Configure its file access with the
|
||||
| `engine.commands` | `{filter?}`: registry with params docs and enablement |
|
||||
| `session.list` | open documents and the active index |
|
||||
| `doc.open` / `doc.new` | `{path}` / `file.new` params |
|
||||
| `doc.save` | `{path?, format?, quality?, index?}` (`.pcraft` native, else export by extension). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged |
|
||||
| `doc.save` | `{path?, format?, quality?, index?}` (`.pcraft` native, else export by extension). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty |
|
||||
| `doc.inspect` | `{index?}`: same JSON as `document.inspect` |
|
||||
| `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` |
|
||||
| `doc.select` / `doc.close` | `{index}` / `{index?}` |
|
||||
|
||||
Reference in new issue
Block a user