Compare commits

...
Author SHA1 Message Date
Claude 5fbc434829 Pipette cursor test: Windows reports the precise crosshair with its bitmap (#1160)
test (windows-latest) fails on main in input_tests::eyedropper_and_alt_sampling_show_a_pipette
("Precise keeps the crosshair": left Crosshair, right None). #1437 expected None on Windows because
the canvas drew the crosshair and hid the OS cursor; since #1160 Windows hands the OS a bitmap
cursor and tool_cursor's end-of-pass hook reports Crosshair underneath it. The test now expects
Crosshair everywhere, plus the bitmap on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 32657d5b26)
2026-10-09 05:35:29 +00:00
Claude 939ab1b978 Merge carry fix 2026-10-09 05:16:43 +00:00
Claude b646566186 Merge refs/tmp/p1572 2026-10-09 05:08:10 +00:00
Claude 37e45bd412 Merge refs/tmp/p1577 2026-10-09 05:08:10 +00:00
Claude 7aff8a23fc Merge refs/tmp/p1587 2026-10-09 05:08:10 +00:00
Claude dda8a7be1e Merge origin/community/1589-psd-save-clean 2026-10-09 05:08:10 +00:00
Claude e45d234e46 Merge refs/tmp/p1592 2026-10-09 05:08:09 +00:00
Claude 91ccbc4283 Merge refs/tmp/p1595 2026-10-09 05:08:09 +00:00
Claude 8ab39b8eec Merge refs/tmp/p1596 2026-10-09 05:08:09 +00:00
Claude 5804c487d1 Merge refs/tmp/p1597 2026-10-09 05:08:09 +00:00
Claude 249abdd57a Merge origin/claude/fix-el-screen-pixel 2026-10-09 05:08:09 +00:00
Claude 8ae49e212a Merge origin/community/1571-1586-diagnostics 2026-10-09 05:08:09 +00:00
Claude 442908b2a9 fmt: rustfmt the #1547 save path and test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:08:01 +00:00
kubuntugamer db0a01078d fix(automation): a pathless PSD or PSB save leaves the document dirty (#1547)
Headless::save recorded the saved revision only for .pcraft, so saving a
document back to its own PSD or PSB wrote the file correctly and still
left session.list reporting dirty: true. Automation clients that use the
flag could not tell a saved document from unsaved work.

The test is now "was this a layered document save" (PSD, PSB or .pcraft)
rather than "was this .pcraft". A flat export (PNG, JPEG, ...) stays a
copy: it leaves dirty and the document's path alone. The desktop app.save
path already recorded the revision for every format, so this only affects
the headless JSON-lines server and the MCP stdio server, which both route
through Headless::save.

Tests cover the reported sequence for both .psd and .psb -- create, reopen
the saved file, edit, then doc.save with no path -- plus a control that a
flat export is a copy. docs/control-protocol.md now states the outcome of
doc.save for the dirty flag and the document path.

Co-authored-by: Space Bunny Free <[email protected]>
2026-10-09 05:07:56 +00:00
buivanvu94 b0e5be848f fix(engine): report invalid index on document.inspect and document.activate (#1557) 2026-10-09 05:07:29 +00:00
buivanvu94 a7046a6caa fix(engine): use grammatical article for adjustment layer error messages (#1549) 2026-10-09 05:07:22 +00:00
Claude 14ed88e399 i18n(el): translate "Pick a screen pixel or press Esc to cancel"
The last screen color picker string #1618 missed: xtask's
source_key_set_is_covered_by_complete_catalogs still fails on main for el.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 05:06:17 +00:00
hideoneric 27653714e9 Snap the Marquee drag outline to whole pixels
- Rectangular and Elliptical Marquee previews use the same pixel rect the release commits, so the ants and size readout match the final selection.
- Extract marquee_preview_px in canvas.rs and use it in draw_drag_preview.
- Add a regression test: a zoomed-in drag between pixel edges previews whole pixels and commits that same rect.
2026-10-09 05:51:05 +02:00
kubuntugamer 6ea2f563e7 fix(io): a layered TIFF export warns that retained EXIF will be dropped (#1545)
A TIFF export with --tiff-layers reported "EXIF not supported; it will be
dropped" even though the file kept the tags. export_layered puts EXIF into
the Photoshop image resources as 1058, which the TIFF writer emits as tag
34377 and the importer reads back, so reopening the file restored the
original tags. ExportResult.warnings is documented as notes about what an
export will lose, so the message was an incorrect loss report for an export
that loses nothing.

The TIFF codec has no EXIF directory, so the second copy handed to it in
meta.exif was never written; it only existed to make the codec warn. That
copy is gone, and with it the false warning. The flat-TIFF path still hands
the codec the tags, still drops them, and still says so.

The test is the issue's own claim checked in both directions: a layered
export reports no EXIF loss AND the Artist tag survives the round trip, so
the missing warning reflects a real absence of loss rather than a silenced
one; a flat export is the control and keeps warning. It fails on the exact
reported message before this change.

Co-authored-by: Space Bunny Free <[email protected]>
2026-10-08 22:48:58 -05:00
Rozaq 9519112ca0 fix: open Image command dialogs before applying 2026-10-09 10:48:19 +07:00
SBaksa d077966b48 fix(raw): a DNG with only DefaultCropSize is cropped from the active area's origin (#950)
The DNG default crop was applied only when both DefaultCropOrigin and
DefaultCropSize were present, so a file carrying just the size was
developed at the whole active area. Each tag now takes its own default
from DNG 1.7.1.0: the origin (0, 0), the size the whole image. The
existing bounds check is unchanged, so an origin-only crop that no
longer fits still falls back to the active area with a warning, and a
tag with the wrong number of values is still reported as incomplete.

Fixes #950.
2026-10-09 14:46:30 +11:00
shmuelbiller1 32d3a5af29 style(format): match rustfmt for autosave worker signature 2026-10-08 23:44:08 -04:00
shmuelbiller1 d4d419461b test(desktop): use bounded wall-clock deadlines for background autosave checks 2026-10-08 23:39:54 -04:00
shmuelbiller1 ea381870e7 test(format): allow slow Windows autosave worker time to finish 2026-10-08 23:39:35 -04:00
shmuelbiller1 691122fb1a fix(format): simplify async autosave completion types for Clippy 2026-10-08 23:39:28 -04:00
Dan HiornsandClaude Opus 5.5 2fcca8f3ae Layers panel: pop-up sliders for Opacity and Fill
Photoshop's Layers panel Opacity and Fill fields have a ▾ that opens a
slider. Add widgets::popup_value_field, a value field with that ▾:

- a click opens the slider below the field; a click outside closes it
- pressing the ▾ and dragging moves the value at once and closes the
  slider on release
- the number can still be typed, scrubbed and stepped with the arrows

A drag (on the slider, from the ▾, or scrubbing the number) sends
layer.setProps live with a per-drag coalesce key, so it is one history
step. The ▾ reuses the translated Opacity/Fill labels (no new strings)
and is disabled on the Background layer, like the fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 21:33:51 -06:00
shmuelbiller1 2052cf2fc8 style(format): apply rustfmt for autosave completion iterator 2026-10-08 23:10:20 -04:00
shmuelbiller1 b3f384a0fe style(ui): compact autosave iterator chains under project rustfmt width 2026-10-08 23:06:32 -04:00
shmuelbiller1 2b6b59682e test(format): report async autosave write failure and successful retry 2026-10-08 23:03:43 -04:00
shmuelbiller1 5a90ed192a test(desktop): retry failed background autosave with unchanged revision 2026-10-08 23:03:18 -04:00
shmuelbiller1 15e102c414 fix(ui): confirm autosaves only after successful writes and retry failures 2026-10-08 23:02:51 -04:00
shmuelbiller1 461647811c fix(desktop): pass worker autosave results to shell 2026-10-08 23:02:21 -04:00
shmuelbiller1 e7c6850c1a feat(ui): accept nonblocking background autosave outcomes 2026-10-08 23:02:12 -04:00
shmuelbiller1 a097239924 fix(format): expose asynchronous autosave completion and enqueue errors 2026-10-08 23:02:00 -04:00
shmuelbiller1 37f9c969a0 style(engine): follow rustfmt for background-job regression test 2026-10-08 22:54:12 -04:00
shmuelbiller1 8bc2e8aa59 style(test): format background job regression assertions 2026-10-08 22:50:43 -04:00
shmuelbiller1 5da0d32ffb test(engine): cover Fade state after switching tabs during background job 2026-10-08 22:49:48 -04:00
shmuelbiller1 4c0c743d55 fix(engine): finish background job bookkeeping on its document 2026-10-08 22:49:21 -04:00
48 changed files with 846 additions and 79 deletions

No files matched your search

+51 -2
View File
@@ -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 {
+8 -4
View File
@@ -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();
}
+16
View File
@@ -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)]
+5 -1
View File
@@ -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> {
+1 -1
View File
@@ -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()))
}
}
+15 -5
View File
@@ -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,
},
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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())),
}
}
+1 -1
View File
@@ -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 {
+5 -1
View File
@@ -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> {
+1 -1
View File
@@ -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())),
}
}
+1 -1
View File
@@ -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())),
}
}
+8 -3
View File
@@ -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) {
+52
View File
@@ -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);
+5 -1
View File
@@ -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 {
+1 -1
View File
@@ -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())),
}
}
+1 -1
View File
@@ -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}))
+1 -1
View File
@@ -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()))
}
}
+1 -1
View File
@@ -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> {
+3 -3
View File
@@ -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);
+34
View File
@@ -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");
}
+4 -1
View File
@@ -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(());
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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)));
+1 -1
View File
@@ -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()),
}
}
+43 -8
View File
@@ -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
+36
View File
@@ -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");
+4 -1
View File
@@ -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),
+38
View File
@@ -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
View File
@@ -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"))?;
+5
View File
@@ -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
View File
@@ -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).
+15 -4
View File
@@ -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 {
+1 -1
View File
@@ -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())),
}
}
+1
View File
@@ -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.
+9 -4
View File
@@ -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");
}
+4
View File
@@ -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>,
+19
View File
@@ -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.
+25 -6
View File
@@ -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::*;
+37 -4
View File
@@ -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}"),
}
}
}
+2
View File
@@ -444,6 +444,8 @@ fn wraps(id: &str) -> bool {
| "view.newGuideLayout"
| "type.warpText"
| "type.pasteLoremIpsum"
| "image.applyImage"
| "image.calculations"
)
}
+12
View File
@@ -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);
+106 -3
View File
@@ -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
+1 -1
View File
@@ -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?}` |