Compare commits

...
Author SHA1 Message Date
Claude 5d921c7859 Merge branch 'community/1240-psd-text-fill' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 3d955b6dc7 Merge branch 'pr-1261' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 7de0dae648 Merge branch 'pr-1279' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 5ea62e5c7a Merge branch 'pr-1269' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 0273f92abe Merge branch 'pr-1268' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 6420a4d5f9 Merge branch 'pr-1306' into claude/integ-batch-1 2026-10-08 18:12:39 +00:00
Claude 103783a2ab Merge branch 'pr-1307' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude 9c4f810d14 Merge branch 'pr-1237' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude a3c34e8221 Merge branch 'pr-1228' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude 0475d60258 Merge branch 'pr-1155' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude e6795f9d70 Merge branch 'pr-1178' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude f8fb5d4595 Merge branch 'pr-1290' into claude/integ-batch-1 2026-10-08 18:12:38 +00:00
Claude 4560d12ac2 PSD text fill: keep the fix and its tests, drop the proof-only screenshots and example
The Photopea screenshots, their ATTRIBUTION row and the example that made
them documented the bug for review; the unit tests in crates/text cover
the behaviour, so they are not needed in the tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 18:12:33 +00:00
Marcelo Cárdenas 48adaa7edf Gradient tool: a handle dragged past the 10% scale floor stops; the other handle stays put (#1243)
Dragging one live-gradient handle towards the other clamps the fill's scale at 10%. For Linear the
centre was still taken from the midpoint of the requested points, so the
clamped gradient was re-centred and the handle that wasn't being dragged slid along the line.
gradient.fill.set now shifts the clamped layout so the handle not given keeps its place. The same
apply_set drives the drag preview, so the canvas matches.
2026-10-08 14:02:18 -03:00
Marcelo Cárdenas 51a5da427e Filter › Last Filter applies to the active layer, not the layer the filter first ran on (#1270)
The Filter Gallery, Camera Raw and Adaptive Wide Angle dialogs record the layer they ran on in
the command's params. Last Filter replayed the journal entry verbatim, so it filtered that layer
again (possibly hidden, or deleted) and appeared to do nothing on the layer you were looking at.
It now drops the recorded `layer`, so it applies to the current layer as in Photoshop.
2026-10-08 14:01:50 -03:00
DWAA1660 7eb04a1f64 PSD export: write empty layer and mask channels as raw 2026-10-08 10:32:21 -06:00
Nigel Ihza Firdausy e4bf997806 docs(scorecard): account for two WebP export preference fields (#1260) 2026-10-08 23:16:24 +07:00
Nigel Ihza Firdausy 067cdd1f68 style: apply rustfmt layout to conditional WebP preference labels 2026-10-08 22:52:58 +07:00
Nigel Ihza Firdausy 72468368e3 style: format WebP export regression test as rustfmt expects 2026-10-08 22:52:53 +07:00
Nigel Ihza Firdausy 8e6f7fffe4 test(export): verify WebP quick export mode, quality, validation, and legacy prefs 2026-10-08 22:50:54 +07:00
Nigel Ihza Firdausy caea32cc51 fix(ui): use WebP quality and lossless preferences in Export As dialog 2026-10-08 22:50:18 +07:00
Nigel Ihza Firdausy cee1d4ee49 fix(ui): show format-specific JPEG and WebP export preference controls 2026-10-08 22:49:49 +07:00
Nigel Ihza Firdausy a7da0ad8f0 fix(export): apply configured WebP quality to Quick Export output 2026-10-08 22:49:06 +07:00
Nigel Ihza Firdausy cd9127b3b5 feat(export): persist WebP lossless mode and quality in export preferences (#1260) 2026-10-08 22:48:47 +07:00
Nigel Ihza Firdausy 6fe7b0e3ba fix(ui): preserve localized quick export labels for selected format 2026-10-08 22:40:22 +07:00
Nigel Ihza Firdausy 5b2f34c6b8 fix(ui): show configured quick export format in File menu (#1259) 2026-10-08 22:37:28 +07:00
Nigel Ihza Firdausy 7cdb227a9f fix(ui): ignore hidden canvas guides during Move tool hit testing (#1258) 2026-10-08 22:36:59 +07:00
Nigel Ihza Firdausy e9cc4826c4 Avoid overlapping borrows when reopening guide layout dialog 2026-10-08 22:10:17 +07:00
Nigel Ihza Firdausy 62172a0cd9 Cover successful, cancelled and failed guide layout dialog state 2026-10-08 22:03:39 +07:00
Nigel Ihza Firdausy 89aab1d218 Save guide layout preferences only after successful application 2026-10-08 22:03:04 +07:00
Nigel Ihza Firdausy a614222b63 Remember last applied New Guide Layout parameters in UI state 2026-10-08 22:02:53 +07:00
Rage 6512c938ad Fix PSD text fill after edits in Photopea 2026-10-08 09:51:55 -05:00
DWAA1660andClaude Opus 5.5 21318c065f UI Scale Auto keeps the system's fractional scale on 4K (#1072)
Auto forced at least 200% on any 4K monitor, overriding a fractional
system scale such as 125% or 150%, so Linux users on 4K got either
100% or 200% and nothing between. Auto now follows whatever scale the
system reports and uses the 200% fallback only when the system reports
no scaling (100%), which is the case #225 was fixing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QcbChgBDVrdhjfsFA5D9nt
2026-10-08 08:37:58 -06:00
liowald 7f0de92453 fix(startup): retain prior crash evidence on windowing errors 2026-10-08 16:21:00 +02:00
liowald e14c0cf9ce fix(startup): preserve GPU backend after non-graphics errors 2026-10-08 16:16:59 +02:00
Cyanoxide 02461a2307 Resolve issue that makes Photocraft save various file formats incorectly as a .psd 2026-10-08 11:58:55 +01:00
Arian Pashae 76c9d65ba8 chore: remove stray fb-clippy.log and fb-test.log and ignore *.log (#1154) 2026-10-08 13:09:31 +03:30
28 changed files with 702 additions and 53 deletions

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ target/
/temp
/book/output
/craft-fonts
*.log
+33
View File
@@ -341,6 +341,12 @@ pub struct Sentinel {
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
/// Only renderer errors should affect the next launch's graphics backend. Windowing errors
/// (such as a missing Linux display) and app setup errors never indicate a driver failure.
pub fn keep_marker_after_run(result: &eframe::Result) -> bool {
matches!(result, Err(eframe::Error::Wgpu(_)))
}
impl Sentinel {
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
/// unless another instance holds the lock or the directory isn't writable, this start's
@@ -599,6 +605,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
("success", Ok(()), false),
("app-error", Err(eframe::Error::AppCreation(Box::new(std::io::Error::other("app setup failed")))), false),
("gpu-error", Err(eframe::Error::Wgpu(egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no adapter".into()))), true),
];
for (name, result, keep) in cases {
let dir = temp_dir(name);
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
if keep_marker_after_run(&result) {
drop(s);
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
}
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn unwritable_config_dir_is_not_fatal() {
let dir = temp_dir("file");
+15 -5
View File
@@ -390,12 +390,22 @@ fn main() -> eframe::Result {
Ok(Box::new(app))
}),
);
// Closed before the first frames rendered: not a driver crash. (A start that failed to
// create its device keeps the marker, so the next one tries a safer backend.)
if result.is_ok()
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
// Closed or failed outside graphics initialization: not a driver crash. A renderer
// error keeps the marker, so the next start tries a safer backend.
if !gpu_startup::keep_marker_after_run(&result)
&& let Some(mut s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
{
s.finish();
if result.is_err()
&& let Some(marker) = previous.crashed()
{
// This failure supplies no new graphics-crash evidence: retain the previous
// marker, rather than recording this attempt's fallback backend.
if let Err(error) = s.write(marker.clone()) {
log::warn!("couldn't restore previous GPU startup marker: {error}");
}
} else {
s.finish();
}
}
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
// Only renderer initialization failures qualify; never restart after editing has begun.
+77 -12
View File
@@ -32,13 +32,17 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("OpenEXR", &["exr"]),
];
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
let mut v = SAVE_FILTERS.to_vec();
if let Some(i) = v.iter().position(|(_, exts)| exts.contains(&ext.as_str())) {
let f = v.remove(i);
v.insert(0, f);
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let f = v.remove(i);
v.insert(0, f);
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
}
v
}
@@ -180,7 +184,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new();
for (name, exts) in save_filters(suggested) {
d = d.add_filter(name, exts);
d = d.add_filter(name, &exts);
}
if let Some(name) = p.file_name() {
d = d.set_file_name(name.to_string_lossy());
@@ -310,14 +314,75 @@ mod tests {
use photocraft_engine::Session;
use photocraft_format::list_recovery;
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::json;
use serde_json::{Value, json};
/// "Export As" formats must lead with their own filter, or the save panel appends the first one's extension (`photo.webp.psd`).
/// Tests every native save dialog and records the suggested file name. Each name's file type must come first
/// in the save panel, or the panel appends the first type's extension (`photo.webp.psd`, `photo.gif.psd`).
#[test]
fn save_filters_lead_with_every_export_format() {
for ext in ["png", "jpg", "webp", "tif", "tga"] {
assert!(save_filters(&format!("photo.{ext}"))[0].1.contains(&ext), "{ext}");
fn every_save_dialog_leads_with_its_own_extension() {
let asked: Rc<RefCell<Vec<String>>> = Rc::default();
let log = asked.clone();
let services = Services {
pick_save: Some(Box::new(move |s: &str| {
log.borrow_mut().push(s.to_string());
None
})),
..Default::default()
};
let mut app = PhotocraftApp::new(Session::new(), services);
let ctx = egui::Context::default();
let invoke = |app: &mut PhotocraftApp, id: &str| photocraft_ui_egui::menus::invoke(app, &ctx, id, json!({}));
let dialog = |app: &mut PhotocraftApp, id: &str, fields: Value| {
let d = invoke(app, id).unwrap()["dialog"].as_u64().unwrap();
for (k, v) in fields.as_object().unwrap() {
app.ui.dialog_mut(d).unwrap().fields.insert(k.clone(), v.clone());
}
let _ = photocraft_ui_egui::dialogs::confirm(app, d);
};
new_doc(&mut app, "#ff0000");
// Save As, Save a Copy.
// Files in a format Save As can't write, like .dng, should suggest .psd instead.
let _ = invoke(&mut app, "file.saveAs");
for name in ["cat.pcraft", "cat.jpeg", "cat.gif", "cat.bmp", "cat.dng"] {
app.session.active_mut().unwrap().path = Some(name.into());
let _ = invoke(&mut app, "file.saveAs");
}
let _ = invoke(&mut app, "file.saveACopy");
// Export As, Quick Export, Save for Web (with and without slices).
for format in ["png", "jpg", "webp", "tif", "tga"] {
dialog(&mut app, "file.export.exportAs", json!({"format": format}));
}
for format in ["png", "jpg", "gif", "webp"] {
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
let _ = invoke(&mut app, "file.export.quickExportAsPng");
}
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
}
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
dialog(&mut app, "file.export.saveForWebLegacy", json!({"html": true}));
// Measurement Log, Export/Import Presets.
app.run("image.analysis.recordMeasurements", json!({})).unwrap();
let _ = invoke(&mut app, "measurementLog.export");
dialog(&mut app, "edit.presets.exportImportPresets", json!({"action": "export"}));
let asked = asked.borrow();
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
// The extension each save above should suggest, in order.
let want = [
"psd", // Save As untitled
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
"psd", // Save a Copy
"png jpg webp tif tga", // Export As
"png jpg gif webp", // Quick Export
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
"csv pcpresets", // Measurement Log, Export Presets
]
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "Photoshop", "no extension keeps the default");
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
@@ -0,0 +1,47 @@
//! A failed Linux desktop launch must not poison the next launch's graphics policy.
#![cfg(target_os = "linux")]
#[test]
fn missing_display_does_not_leave_a_gpu_crash_marker() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let prefs = br#"{"performance":{"gpuBackend":"vulkan","useGpu":true}}"#;
std::fs::write(dir.join("preferences.json"), prefs).unwrap();
let error = launch_without_display(&dir);
assert!(!dir.join("gpu-starting.json").exists(), "a display error is not a GPU crash: {error}");
assert_eq!(std::fs::read(dir.join("preferences.json")).unwrap(), prefs);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn missing_display_preserves_existing_gpu_crash_evidence() {
let dir = std::env::temp_dir().join(format!("photocraft-no-display-prior-crash-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let marker = serde_json::json!({
"backend": "vulkan", "adapter": "Test GPU", "adapterBackend": "vulkan",
"driver": "test", "version": "test"
});
std::fs::write(dir.join("gpu-starting.json"), serde_json::to_vec(&marker).unwrap()).unwrap();
launch_without_display(&dir);
let restored: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("gpu-starting.json")).unwrap()).unwrap();
assert_eq!(restored, marker, "a windowing error must not erase or advance prior GPU crash evidence");
std::fs::remove_dir_all(&dir).unwrap();
}
fn launch_without_display(dir: &std::path::Path) -> String {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.env("PHOTOCRAFT_CONFIG_DIR", dir)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WGPU_BACKEND")
.env_remove("PHOTOCRAFT_GPU_STARTUP_FAILURE")
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.output()
.unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "a desktop cannot open without a display");
assert!(error.contains("WAYLAND_DISPLAY") && error.contains("DISPLAY"), "expected a display error, got: {error}");
error.into_owned()
}
+26 -2
View File
@@ -351,9 +351,15 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The most recent filter command in the session journal.
/// The most recent filter command in the session journal, retargeted at the active layer: dialogs
/// such as the Filter Gallery record the layer they ran on, and Last Filter applies to the
/// current layer, as in Photoshop (#1270).
fn last_filter(s: &Session) -> Option<(String, Value)> {
s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()
let (id, mut params) = s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()?;
if let Value::Object(m) = &mut params {
m.remove("layer");
}
Some((id, params))
}
#[cfg(test)]
@@ -528,6 +534,24 @@ mod tests {
assert_eq!(active_pixels(&s2), twice);
}
#[test]
fn last_filter_applies_to_the_active_layer() {
let mut s = session();
paint_pattern(&mut s);
let first = s.active().unwrap().active_layer.unwrap();
// Dialogs (Filter Gallery, Camera Raw…) record the layer they ran on.
s.execute("filter.other.offset", json!({"horizontal": 3, "layer": first.0})).unwrap();
let first_once = active_pixels(&s);
s.execute("layer.new.layer", json!({})).unwrap();
paint_pattern(&mut s);
let before = active_pixels(&s);
s.execute("filter.lastFilter", json!({})).unwrap();
assert_ne!(active_pixels(&s), before, "the active layer is filtered");
let d = s.active().unwrap();
let l = d.doc.layer(first).unwrap();
assert_eq!(l.surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 48, 32)), first_once, "the first layer is left alone");
}
#[test]
fn params_map_to_algorithm_units() {
assert_eq!(params_for("filter.blur.gaussianBlur", &json!({"radius": 4.5})), Some(FilterParams::GaussianBlur { radius: 4.5 }));
+18 -1
View File
@@ -222,8 +222,25 @@ pub fn apply_set(layer: &Layer, f: &Fill, canvas: Rect, p: &Value, fg: [f32; 4],
}
if from.is_some() || to.is_some() || (style_changed && !explicit_geometry) {
let cur = if from.is_some() || to.is_some() { gf::handles(*style, *angle, *scale, *offset, frame) } else { old_handles };
let (a, s, o) = gf::from_handles(*style, from.unwrap_or(cur.0), to.unwrap_or(cur.1), frame, *angle);
let (f0, t0) = (from.unwrap_or(cur.0), to.unwrap_or(cur.1));
let (a, s, o) = gf::from_handles(*style, f0, t0, frame, *angle);
(*angle, *scale, *offset) = (a, clamp_scale(s), o);
// Past the scale range the dragged handle stops; the other one stays where it is (#1243).
// (Linear centres on the midpoint, so a clamped scale would otherwise move both ends.)
let anchor = match (from, to) {
(Some(_), None) => Some((t0, false)),
(None, Some(_)) => Some((f0, true)),
_ => None,
};
if let Some((pin, is_start)) = anchor
&& *scale != s
{
let (f1, t1) = gf::handles(*style, *angle, *scale, *offset, frame);
let at = if is_start { f1 } else { t1 };
let (dx, dy) = (pin[0] - at[0], pin[1] - at[1]);
let (w, h) = (frame.width().max(1) as f32, frame.height().max(1) as f32);
*offset = (offset.0 + dx / w, offset.1 + dy / h);
}
}
if let Some(v) = p.get("stops") {
let arr = v.as_array().ok_or_else(|| bad(CMD, "`stops` is [[location, colour], …]"))?;
@@ -297,3 +297,29 @@ fn commands_fail_gracefully() {
let g = s.execute(SET, json!({"scale": 1})).unwrap();
assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}");
}
/// Dragging one handle past the scale range stops it; the other handle stays put (#1243).
#[test]
fn a_clamped_handle_drag_leaves_the_other_handle_alone() {
let pt = |g: &Value, k: &str| [g[k][0].as_f64().unwrap(), g[k][1].as_f64().unwrap()];
let close = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-2 && (a[1] - b[1]).abs() < 1e-2;
for style in STYLES {
for (from, to, short, short_start) in [([14.0, 16.0], [34.0, 16.0], [15.0, 16.0], [33.0, 16.0]), ([24.0, 6.0], [24.0, 26.0], [24.0, 7.0], [24.0, 25.0])]
{
let mut s = session(8, "white");
s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap();
// End dragged almost onto the start: far below 10 %.
let g = s.execute(SET, json!({"to": short})).unwrap();
assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{style}: {g}");
assert!(close(pt(&g, "from"), from), "{style}: start moved to {:?}", pt(&g, "from"));
// The end stops on the line towards the pointer.
let e = pt(&g, "to");
assert!((e[0] - from[0]) * (short[0] - from[0]) >= 0.0 && (e[1] - from[1]) * (short[1] - from[1]) >= 0.0, "{style}: {e:?}");
// And the same for the start handle.
let mut s = session(8, "white");
s.execute(CREATE, json!({"from": from, "to": to, "style": style})).unwrap();
let g = s.execute(SET, json!({"from": short_start})).unwrap();
assert!(close(pt(&g, "to"), to), "{style}: end moved: {g}");
}
}
}
+6 -1
View File
@@ -364,6 +364,9 @@ pub struct Export {
pub quick_export_format: QuickExportFormat,
pub quick_export_location: ExportLocation,
pub jpeg_quality: u32,
/// Keep the existing lossless Quick Export default until the user opts into lossy WebP.
pub webp_lossless: bool,
pub webp_quality: u32,
pub metadata: ExportMetadata,
pub convert_to_srgb: bool,
}
@@ -374,6 +377,8 @@ impl Default for Export {
quick_export_format: QuickExportFormat::Png,
quick_export_location: ExportLocation::Ask,
jpeg_quality: 85,
webp_lossless: true,
webp_quality: 85,
metadata: ExportMetadata::Copyright,
convert_to_srgb: true,
}
@@ -905,7 +910,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
Some(match path {
"fileHandling.autosaveMinutes" => (1.0, 240.0),
"fileHandling.recentFileCount" => (0.0, 100.0),
"export.jpegQuality" => (1.0, 100.0),
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
"performance.memoryUsageMb" => (256.0, 1_048_576.0),
"performance.historyStates" => (1.0, 1000.0),
"performance.cacheLevels" => (1.0, 8.0),
+8 -3
View File
@@ -840,6 +840,8 @@ fn export_preferences(s: &mut Session, p: &Value) -> Result<Value> {
("quickExportFormat", "export.quickExportFormat"),
("quickExportLocation", "export.quickExportLocation"),
("jpegQuality", "export.jpegQuality"),
("webpLossless", "export.webpLossless"),
("webpQuality", "export.webpQuality"),
("metadata", "export.metadata"),
("convertToSrgb", "export.convertToSrgb"),
] {
@@ -897,9 +899,12 @@ fn quick_export(s: &mut Session, p: &Value) -> Result<Value> {
o.insert("format".into(), json!("gif"));
}
"webp" => {
// WebP has no legacy Save for Web optimiser: write it through the regular exporter.
// WebP uses the regular exporter. Its optional quality is in the Photoshop-style
// 0–12 save scale: invert that mapping so the codec receives the chosen 1–100.
// Keeping quality absent preserves the historical lossless Quick Export behavior.
let (wdoc, _, _) = web_document(&doc, &json!({}), &WebSettings { convert_to_srgb: prefs.convert_to_srgb, ..Default::default() })?;
let warnings = crate::file_cmds::save_doc(&wdoc, &path, None)?;
let quality = if prefs.webp_lossless { None } else { Some((f64::from(prefs.webp_quality.clamp(1, 100)) - 1.0) * 12.0 / 99.0) };
let warnings = crate::file_cmds::save_doc(&wdoc, &path, quality)?;
crate::automate_cmds::fire_event(s, "export");
return Ok(json!({"path": path, "format": fmt, "warnings": warnings}));
}
@@ -1211,7 +1216,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Preferences…",
&["File", "Export"],
None,
r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
r##"{"quickExportFormat":"png|jpg|gif|webp"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"##,
|_| Ok(()),
export_preferences
),
+48
View File
@@ -153,6 +153,54 @@ fn export_preferences_drive_quick_export() {
s.execute("prefs.reset", json!({"path": "export"})).unwrap();
}
#[test]
fn webp_export_preferences_select_lossless_or_lossy_output() {
let dir = tmp("quick_webp_quality");
let mut s = session(8);
let defaults = s.prefs().export.clone();
assert!(defaults.webp_lossless, "existing Quick Export defaults to lossless WebP");
assert_eq!(defaults.webp_quality, 85);
// Simulate an older stored preference section without the newly added WebP keys.
let mut old = s.prefs().to_json();
old["export"].as_object_mut().unwrap().remove("webpLossless");
old["export"].as_object_mut().unwrap().remove("webpQuality");
let restored: crate::prefs::Preferences = serde_json::from_value(old).unwrap();
assert!(restored.export.webp_lossless);
assert_eq!(restored.export.webp_quality, 85);
let r = s.execute("file.export.exportPreferences", json!({"quickExportFormat": "webp", "webpLossless": true, "webpQuality": 40})).unwrap();
assert_eq!(r["values"]["webpLossless"], true);
assert_eq!(r["values"]["webpQuality"], 40);
let lossless_path = format!("{dir}/lossless.webp");
s.execute("file.export.quickExport", json!({"path": lossless_path})).unwrap();
let lossless = std::fs::read(&lossless_path).unwrap();
assert!(lossless.starts_with(b"RIFF") && &lossless[8..12] == b"WEBP");
assert!(lossless.windows(4).any(|w| w == b"VP8L"), "lossless WebP should use VP8L");
assert_eq!(decode(&lossless_path).dimensions(), (64, 48));
s.execute("file.export.exportPreferences", json!({"webpLossless": false})).unwrap();
let low_path = format!("{dir}/lossy40.webp");
s.execute("file.export.quickExport", json!({"path": low_path})).unwrap();
let low = std::fs::read(&low_path).unwrap();
assert!(low.windows(4).any(|w| w == b"VP8 "), "lossy WebP should contain a VP8 frame");
assert_eq!(decode(&low_path).dimensions(), (64, 48));
s.execute("file.export.exportPreferences", json!({"webpQuality": 90})).unwrap();
let high_path = format!("{dir}/lossy90.webp");
s.execute("file.export.quickExport", json!({"path": high_path})).unwrap();
let high = std::fs::read(&high_path).unwrap();
assert!(high.windows(4).any(|w| w == b"VP8 "));
assert_eq!(decode(&high_path).dimensions(), (64, 48));
assert_ne!(low, high, "changing WebP quality should change the encoded image");
let before = s.prefs().export.webp_quality;
for quality in [0, 101] {
assert!(s.execute("file.export.exportPreferences", json!({"webpQuality": quality})).is_err());
assert_eq!(s.prefs().export.webp_quality, before);
}
}
#[test]
fn generator_naming_grammar() {
let a = parse_asset_name("200% [email protected], 48x48 icons/bar.png8 + photo.jpg8", 72.0);
+5 -1
View File
@@ -244,7 +244,11 @@ impl Ex {
fn encode(&self, id: i16, plane: &[u8], w: usize, h: usize) -> ChannelData {
let depth = psd_depth(self.fmt.sample);
ChannelData::encode(id, self.compression(), plane, w, h, depth, self.version)
// Photoshop writes empty layer/mask channels as just the raw compression code.
// A ZIP stream for a zero-sized channel makes readers try to decompress an
// invalid image extent (notably psd-tools for 32-bit adjustment layers).
let compression = if w == 0 || h == 0 { Compression::Raw } else { self.compression() };
ChannelData::encode(id, compression, plane, w, h, depth, self.version)
.or_else(|_| ChannelData::encode(id, Compression::Raw, plane, w, h, depth, self.version))
.unwrap_or(ChannelData { id, compression: Some(Compression::Raw), data: plane.to_vec() })
}
+44
View File
@@ -0,0 +1,44 @@
mod common;
use common::Features;
use photocraft_color::{ColorMode, SampleType};
use photocraft_io::{ExportOptions, export};
use photocraft_psd::{Compression, PsdFile};
#[test]
fn empty_layer_and_mask_channels_use_raw_without_a_payload() {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for ext in ["out.psd", "out.psb"] {
let mut doc = common::gen_doc(mode, depth, Features::ALL);
let mut masked = common::raster("Uniform mask", doc.pixel_format(), photocraft_geom::Rect::new(0, 0, 3, 3), 1, true);
masked.mask = Some(photocraft_doc::LayerMask {
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, depth, false)),
enabled: true,
linked: true,
density: 1.0,
feather: 0.0,
});
doc.layers.push(masked);
let bytes = export(&doc, ext, &ExportOptions::default()).unwrap().bytes;
let file = PsdFile::from_bytes(&bytes).unwrap();
let mut empty = 0;
let mut empty_masks = 0;
for layer in file.layers() {
for channel in &layer.channels {
let rect = layer.channel_rect(channel.id);
if rect.width() == 0 || rect.height() == 0 {
empty += 1;
if channel.id == -2 {
empty_masks += 1;
}
assert_eq!(channel.compression, Some(Compression::Raw), "{mode:?} {depth:?} {ext}: {} channel {}", layer.name(), channel.id);
assert!(channel.data.is_empty());
}
}
}
assert!(empty > 0);
assert!(empty_masks > 0);
}
}
}
}
+2
View File
@@ -594,6 +594,8 @@ pub(crate) fn style_sheet_data(s: &CharStyle, font: usize, k: f32) -> E {
("Strikethrough".into(), E::Bool(s.strikethrough)),
("Ligatures".into(), E::Bool(s.ligatures)),
("DLigatures".into(), E::Bool(s.discretionary_ligatures)),
// Photopea lays out edited text without painting it unless its fill is enabled.
("FillFlag".into(), E::Bool(true)),
("FillColor".into(), E::Dict(vec![("Type".into(), E::Int(color_type)), ("Values".into(), E::Array(values))])),
];
dict.append(&mut opentype);
+69
View File
@@ -545,6 +545,75 @@ fn txt2_carries_optical_kerning() {
assert!(l.char_runs().iter().all(|r| r.style.kerning == Kerning::Optical));
}
/// Photopea needs an enabled fill, not just FillColor, to paint text after an edit. Inspect the
/// serialized TySh rather than our renderer, which does not consume the PSD fill flag.
#[test]
fn psd_text_fill_is_enabled_in_runs_and_new_default_styles() {
let mut t = point("AB", 100.0);
t.runs = vec![
TextRun { len: 1, style: CharStyle { color: Color::rgb(0.2, 0.4, 0.6), ..Default::default() } },
TextRun { len: 1, style: CharStyle { faux_bold: true, size_pt: 100.0, ..Default::default() } },
];
for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 200.0 }] {
t.shape = shape;
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
let data = crate::psd::engine_data(&tysh.text).unwrap();
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
assert_eq!(runs.len(), 2);
for run in runs {
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
}
for key in ["ResourceDict", "DocumentResources"] {
let sheets = data.path(&[key, "StyleSheetSet"]).unwrap().as_array().unwrap();
assert_eq!(sheets[0].path(&["StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
}
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap();
assert_eq!(back.text, t.text);
assert_eq!(back.runs.len(), t.runs.len());
for (actual, expected) in back.runs.iter().zip(&t.runs) {
assert_eq!(actual.style.color, expected.style.color);
assert_eq!(actual.style.size_pt, expected.style.size_pt);
assert_eq!(actual.style.faux_bold, expected.style.faux_bold);
}
}
}
#[test]
fn psd_text_fill_is_enabled_after_editing_a_legacy_export() {
use crate::engine_data::Value as E;
// A legacy PhotoCraft export has a normal style and character runs without FillFlag.
let mut t = point("Before", 40.0);
let mut legacy = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
fn remove_fill_flags(value: &mut E) {
match value {
E::Dict(items) => {
items.retain(|(key, _)| key != "FillFlag");
for (_, value) in items {
remove_fill_flags(value);
}
}
E::Array(items) => items.iter_mut().for_each(remove_fill_flags),
_ => {}
}
}
let mut data = crate::psd::engine_data(&legacy.text).unwrap();
remove_fill_flags(&mut data);
for (key, value) in &mut legacy.text.items {
if key.as_bytes() == b"EngineData" {
*value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data));
}
}
t.psd_raw = Some(crate::psd::write_tysh(&legacy).into());
t.text = "After".into();
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
let data = crate::psd::engine_data(&tysh.text).unwrap();
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
assert!(!runs.is_empty());
for run in runs {
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(E::as_bool), Some(true));
}
}
/// EngineData pair fields: manual kerning and "no automatic kerning" round-trip exactly, in
/// Photoshop's form (see `psd::pair_runs`).
#[test]
+5 -1
View File
@@ -324,7 +324,11 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
app.filter_preview = None;
let cmd = d.fields.get("__command").and_then(|v| v.as_str().map(str::to_string)).ok_or("dialog has no command")?;
let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields));
app.run(&cmd, params)
let result = app.run(&cmd, params.clone());
if result.is_ok() && cmd == "view.newGuideLayout" {
app.ui.view.guide_layout = params;
}
result
}
DialogKind::LayerStyle => crate::layer_style::confirm(app, &d.fields),
DialogKind::About | DialogKind::Error => Ok(Value::Null),
+41 -3
View File
@@ -19,8 +19,8 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
f.insert("__export".into(), json!(true));
f.insert("__label".into(), json!("Export As"));
f.insert("format".into(), json!("png"));
f.insert("quality".into(), json!(85));
f.insert("lossless".into(), json!(false));
f.insert("quality".into(), json!(app.session.prefs().export.jpeg_quality));
f.insert("lossless".into(), json!(app.session.prefs().export.webp_lossless));
f.insert("transparency".into(), json!(true));
f.insert("scale".into(), json!(100));
f.insert("metadata".into(), json!("none"));
@@ -92,6 +92,22 @@ fn export_document(doc: &Document, f: &Map<String, Value>, max_side: Option<u32>
s.active().map(|d| (*d.doc).clone()).ok_or_else(|| "export failed".into())
}
/// Export As already supports lossy WebP; reuse the Quick Export defaults when
/// choosing that format so both flows expose the same quality and lossless options.
fn set_format_defaults(f: &mut Map<String, Value>, fmt: &str, prefs: &photocraft_engine::prefs::Export) {
f.insert("format".into(), json!(fmt));
match fmt {
"jpg" => {
f.insert("quality".into(), json!(prefs.jpeg_quality));
}
"webp" => {
f.insert("quality".into(), json!(prefs.webp_quality));
f.insert("lossless".into(), json!(prefs.webp_lossless));
}
_ => {}
}
}
fn s_fmt(f: &Map<String, Value>) -> String {
let v = s(f, "format");
if v.is_empty() { "png".into() } else { v }
@@ -137,7 +153,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
let mut fmt = s_fmt(f);
let opts: Vec<(String, &str)> = FORMATS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
if crate::widgets::dropdown(ui, "export-format", &mut fmt, &opts, 130.0) {
f.insert("format".into(), json!(fmt));
set_format_defaults(f, &fmt, &app.session.prefs().export);
}
});
let fmt = s_fmt(f);
@@ -287,6 +303,28 @@ mod tests {
assert_eq!(settings(&f).jpeg_quality, Some(85));
}
#[test]
fn switching_export_formats_uses_independent_quality_preferences() {
let prefs = photocraft_engine::prefs::Export { jpeg_quality: 43, webp_quality: 72, webp_lossless: false, ..Default::default() };
let mut fields = Map::new();
set_format_defaults(&mut fields, "webp", &prefs);
assert_eq!(fields["quality"], 72);
assert_eq!(fields["lossless"], false);
let s = settings(&fields);
assert!(!s.webp_lossless);
assert_eq!(s.webp_quality, Some(72));
assert_eq!(s.jpeg_quality, None);
set_format_defaults(&mut fields, "jpg", &prefs);
assert_eq!(fields["quality"], 43);
assert_eq!(settings(&fields).jpeg_quality, Some(43));
assert_eq!(settings(&fields).webp_quality, None);
set_format_defaults(&mut fields, "webp", &photocraft_engine::prefs::Export::default());
assert!(settings(&fields).webp_lossless, "default WebP Quick Export mode is lossless");
assert_eq!(settings(&fields).webp_quality, None);
}
#[test]
fn webp_settings_follow_the_lossless_switch() {
let mut f = Map::new();
+9 -2
View File
@@ -835,8 +835,15 @@ impl PhotocraftApp {
return Ok(("smart object".into(), Vec::new()));
}
let st = self.session.active().ok_or("no document")?;
// Documents are named after their file ("cat.png"): suggest "cat.psd", not "cat.png.psd".
let suggested = st.path.clone().unwrap_or_else(|| format!("{}.psd", st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(stem, _)| stem)));
// Suggest the file's own name if Save As can write its format, otherwise switch to .psd.
let ext = st.path.as_deref().and_then(|p| std::path::Path::new(p).extension()).map(|e| e.to_string_lossy().to_ascii_lowercase());
let writable = ext.is_some_and(|e| {
matches!(e.as_str(), photocraft_format::EXTENSION | "psd" | "psb") || photocraft_codecs::from_extension(&e).is_some_and(|f| f.caps().write)
});
let suggested = match &st.path {
Some(p) if writable => p.clone(),
p => std::path::Path::new(p.as_deref().unwrap_or(&st.doc.name)).with_extension("psd").to_string_lossy().into_owned(),
};
let path = match path {
Some(p) => p,
None => self.services.pick_save.as_mut().and_then(|f| f(&suggested)).ok_or("cancelled")?,
+55 -4
View File
@@ -614,6 +614,17 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
})
}
/// Translate the fixed command label and substitute the currently configured export format.
/// Reuse the existing translated PNG sentence, so dynamic formats work in every UI language.
fn translated_menu_label(lang: crate::i18n::Lang, item: &MenuItem) -> String {
if item.id == "file.export.quickExportAsPng"
&& let Some(fmt) = item.label.strip_prefix("Quick Export as ")
{
return crate::i18n::tr_id(lang, &item.id, "Quick Export as PNG").replace("PNG", fmt);
}
crate::i18n::tr_id(lang, &item.id, &item.label).to_string()
}
/// Menu tree entry for rendering and for `ui.inspect`.
#[derive(Clone, Debug, serde::Serialize)]
pub struct MenuItem {
@@ -701,6 +712,23 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
}
}
crate::plugin_ui::insert_menu_items(app, &mut items);
// The File quick-export command uses the format selected in Export Preferences.
// On the web it intentionally downloads PNG until the quick-export service supports
// the other formats; the Layer quick-export command also always produces PNG.
if let Some(item) = items.iter_mut().find(|i| i.id == "file.export.quickExportAsPng") {
use photocraft_engine::prefs::QuickExportFormat;
let format = if cfg!(target_arch = "wasm32") {
"PNG"
} else {
match app.session.prefs().export.quick_export_format {
QuickExportFormat::Png => "PNG",
QuickExportFormat::Jpg => "JPG",
QuickExportFormat::Gif => "GIF",
QuickExportFormat::Webp => "WebP",
}
};
item.label = format!("Quick Export as {format}");
}
// File › Open Recent: a dynamic submenu of recently opened files (inserted after "Open As…").
if let Some(after) = items.iter().position(|i| i.id == "file.openAs") {
let rp: Vec<String> = vec!["File".into(), "Open Recent".into()];
@@ -938,7 +966,7 @@ pub fn search_items<'a>(items: &'a [MenuItem], query: &str, lang: crate::i18n::L
let rank = |it: &MenuItem| {
let english = search_rank(&q, &it.label, &it.path);
let path: Vec<String> = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect();
let local = search_rank(&q, crate::i18n::tr_id(lang, &it.id, &it.label), &path);
let local = search_rank(&q, &translated_menu_label(lang, it), &path);
english.into_iter().chain(local).min()
};
let mut hits: Vec<(u8, usize, &MenuItem)> =
@@ -978,8 +1006,8 @@ fn help_search(ui: &mut egui::Ui, items: &[MenuItem], clicked: &mut Option<Strin
ui.weak(crate::i18n::tr(lang, "No matching commands"));
}
for it in results {
let mut trail: Vec<&str> = it.path.iter().map(|p| crate::i18n::tr(lang, p)).collect();
trail.push(crate::i18n::tr_id(lang, &it.id, &it.label));
let mut trail: Vec<String> = it.path.iter().map(|p| crate::i18n::tr(lang, p).to_string()).collect();
trail.push(translated_menu_label(lang, it));
let mut b = egui::Button::new(trail.join(" › "));
if let Some(sc) = &it.shortcut {
b = b.shortcut_text(crate::shortcuts::pretty(sc));
@@ -1038,7 +1066,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click
}
continue;
}
let mut text = crate::i18n::tr_id(lang, &it.id, &it.label).to_string();
let mut text = translated_menu_label(lang, it);
if let Some(c) = it.checked {
text = format!("{} {}", if c { "✔" } else { " " }, text);
}
@@ -1461,3 +1489,26 @@ mod open_recent_tests {
let _ = std::fs::remove_file(&path);
}
}
#[cfg(test)]
mod quick_export_label_tests {
use super::*;
#[test]
fn file_quick_export_menu_reflects_selected_format_without_renaming_layer_export() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP")] {
app.run("prefs.set", json!({"values": {"export.quickExportFormat": value}})).unwrap();
let items = menu_items(&app);
let quick: Vec<_> = items.iter().filter(|i| i.id == "file.export.quickExportAsPng").collect();
assert_eq!(quick.len(), 1, "the catalogue and UI command must not create duplicate items");
let shown_format = if cfg!(target_arch = "wasm32") { "PNG" } else { expected };
assert_eq!(quick[0].label, format!("Quick Export as {shown_format}"));
let id = crate::i18n::Lang::from_code("id").unwrap();
let localized = translated_menu_label(id, quick[0]);
assert_eq!(localized, format!("Ekspor Cepat ke {shown_format}"));
let layer = items.iter().find(|i| i.id == "layer.quickExportAsPng").unwrap();
assert_eq!(layer.label, "Quick Export as PNG", "layer export always creates PNG");
}
}
}
+66 -7
View File
@@ -161,9 +161,13 @@ fn display_scale(pref: prefs::UiScale, native: Option<f32>, monitor_px: Option<e
let native = native.filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0);
match pref {
prefs::UiScale::Auto => {
// A 4K display needs at least 200%; preserve larger system scales.
// Follow the system's scale, fractional ones included (125%, 150%, 175%: #1072).
// Only when the system reports no scaling at all (100%) does a 4K display fall back
// to 200%, since unscaled 4K controls are unreadably small (#225). Overriding a scale
// the user picked in their desktop settings left only "too small" or "too big".
let unscaled = (native - 1.0).abs() < 0.01;
let is_4k = monitor_px.is_some_and(|s| s.x.is_finite() && s.y.is_finite() && s.x.min(s.y) >= 2160.0 && s.x.max(s.y) >= 3840.0);
if is_4k { native.max(2.0) } else { native }
if unscaled && is_4k { 2.0 } else { native }
}
fixed => fixed.name().parse::<f32>().map_or(1.0, |pct| pct / 100.0),
}
@@ -756,6 +760,13 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
}
fn humanize(key: &str) -> String {
// These controls appear only for WebP, so reuse the existing translated labels.
if key == "webpLossless" {
return "Lossless".into();
}
if key == "webpQuality" {
return "Quality".into();
}
let mut s = String::new();
for (i, ch) in key.chars().enumerate() {
if i == 0 {
@@ -940,6 +951,17 @@ fn has_visible_fields(values: &Value, section: &str) -> bool {
values.get(section).and_then(Value::as_object).is_some_and(|o| o.keys().any(|k| !prefs::is_hidden(&format!("{section}.{k}"))))
}
/// JPEG and WebP have independent settings; unrelated format controls stay out of view.
fn export_field_visible(obj: &Map<String, Value>, key: &str) -> bool {
let format = obj.get("quickExportFormat").and_then(Value::as_str).unwrap_or("png");
match key {
"jpegQuality" => format == "jpg",
"webpLossless" => format == "webp",
"webpQuality" => format == "webp" && obj.get("webpLossless").and_then(Value::as_bool) != Some(true),
_ => true,
}
}
/// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches,
/// number fields with the preference's range, text fields.
fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>, order: &[String], lang: crate::i18n::Lang) {
@@ -954,7 +976,10 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>
let path = format!("{section}.{k}");
// Settings nothing reads yet stay out of the dialog (issue #204); their stored values
// pass through untouched.
if prefs::is_hidden(&path) || (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode")) {
if prefs::is_hidden(&path)
|| (section == "performance" && matches!(k.as_str(), "useGpu" | "gpuBackend" | "renderingMode"))
|| (section == "export" && !export_field_visible(obj, &k))
{
continue;
}
let v = obj.get(&k).cloned().unwrap_or(Value::Null);
@@ -1431,6 +1456,29 @@ mod tests {
assert_eq!(*h.state(), [0x80, 0x80, 0x80]);
}
#[test]
fn quick_export_controls_match_selected_format() {
let mut obj = serde_json::to_value(photocraft_engine::prefs::Export::default()).unwrap().as_object().unwrap().clone();
assert!(!export_field_visible(&obj, "jpegQuality"));
assert!(!export_field_visible(&obj, "webpLossless"));
assert!(!export_field_visible(&obj, "webpQuality"));
obj.insert("quickExportFormat".into(), json!("jpg"));
assert!(export_field_visible(&obj, "jpegQuality"));
assert!(!export_field_visible(&obj, "webpLossless"));
assert!(!export_field_visible(&obj, "webpQuality"));
obj.insert("quickExportFormat".into(), json!("webp"));
assert!(!export_field_visible(&obj, "jpegQuality"));
assert!(export_field_visible(&obj, "webpLossless"));
assert!(!export_field_visible(&obj, "webpQuality"), "lossless WebP does not have a quality setting");
obj.insert("webpLossless".into(), json!(false));
assert!(export_field_visible(&obj, "webpQuality"));
assert_eq!(humanize("webpLossless"), "Lossless");
assert_eq!(humanize("webpQuality"), "Quality");
}
#[test]
fn rendering_mode_display_respects_explicit_mode_and_legacy_disable() {
let legacy = serde_json::json!({"useGpu": false, "gpuBackend": "auto"});
@@ -1597,12 +1645,20 @@ mod tests {
}
#[test]
fn auto_scale_detects_4k_and_preserves_larger_system_dpi() {
fn auto_scale_detects_4k_and_preserves_system_scale() {
use prefs::UiScale::Auto;
for size in [vec2(3840.0, 2160.0), vec2(4096.0, 2160.0), vec2(2160.0, 3840.0)] {
assert_eq!(display_scale(Auto, Some(1.0), Some(size)), 2.0);
for dpi in [1.25, 1.5, 2.0] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), 2.0);
// A fractional system scale is the user's choice and is kept as is (#1072).
for dpi in [1.25, 1.5, 1.75, 2.0, 2.5] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
}
// Below 100% is a system choice too, even on 4K.
assert_eq!(display_scale(Auto, Some(0.75), Some(vec2(3840.0, 2160.0))), 0.75);
for size in [vec2(1920.0, 1080.0), vec2(2560.0, 1440.0)] {
for dpi in [1.25, 1.5, 1.75] {
assert_eq!(display_scale(Auto, Some(dpi), Some(size)), dpi);
}
}
assert_eq!(display_scale(Auto, Some(3.0), Some(vec2(3840.0, 2160.0))), 3.0);
@@ -1636,7 +1692,10 @@ mod tests {
step(vec2(3840.0, 2160.0), 1.0, 2.0);
}
step(vec2(1920.0, 1080.0), 1.0, 1.0);
step(vec2(3840.0, 2160.0), 1.5, 2.0);
step(vec2(3840.0, 2160.0), 1.5, 1.5);
step(vec2(3840.0, 2160.0), 1.25, 1.25);
step(vec2(2560.0, 1440.0), 1.75, 1.75);
step(vec2(3840.0, 2160.0), 1.0, 2.0);
}
for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("auto", 1.5)] {
app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap();
+42 -1
View File
@@ -124,7 +124,9 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
/// Existing guide under a document point (within 4 screen px).
pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
if !app.ui.extras.guides || app.ui.extras.lock_guides {
// Hidden canvas guides are not interactive: the Move tool must reach the layer below.
// Keep hit testing consistent with the visibility gate in canvas::draw.
if !app.ui.extras.guides || !app.ui.view.shows(app.ui.view.show.canvas_guides) || app.ui.extras.lock_guides {
return None;
}
let doc = &app.session.active()?.doc;
@@ -269,6 +271,45 @@ mod tests {
assert_eq!(tick_step(0.25), 500.0);
}
#[test]
fn hidden_guides_are_not_hit_tested() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap();
app.sync_views();
app.session.execute("view.newGuide", json!({"orientation": "vertical", "position": 50})).unwrap();
app.session.execute("view.newGuide", json!({"orientation": "horizontal", "position": 25})).unwrap();
let positions = app.session.active().unwrap().doc.guides.clone();
// Visible, unlocked guides remain interactive in either orientation.
assert_eq!(guide_at(&app, 50.0, 10.0), Some((true, 0)));
assert_eq!(guide_at(&app, 10.0, 25.0), Some((false, 0)));
// All ways to hide the canvas guides must also disable hit testing.
app.ui.extras.guides = false;
assert_eq!(guide_at(&app, 50.0, 10.0), None);
assert_eq!(guide_at(&app, 10.0, 25.0), None);
app.ui.extras.guides = true;
app.ui.view.show.canvas_guides = false;
assert_eq!(guide_at(&app, 50.0, 10.0), None);
assert_eq!(guide_at(&app, 10.0, 25.0), None);
app.ui.view.show.canvas_guides = true;
app.ui.view.extras = false;
assert_eq!(guide_at(&app, 50.0, 10.0), None);
assert_eq!(guide_at(&app, 10.0, 25.0), None);
app.ui.view.extras = true;
// Hiding/showing must not change the stored guide positions.
assert_eq!(app.session.active().unwrap().doc.guides, positions);
assert_eq!(guide_at(&app, 50.0, 10.0), Some((true, 0)));
assert_eq!(guide_at(&app, 10.0, 25.0), Some((false, 0)));
app.ui.extras.lock_guides = true;
assert_eq!(guide_at(&app, 50.0, 10.0), None);
assert_eq!(guide_at(&app, 10.0, 25.0), None);
}
#[test]
fn guide_drags_create_move_and_delete() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
+9 -1
View File
@@ -94,6 +94,12 @@ pub struct ViewOptions {
/// and the Middle Eastern & South Asian composer.
pub language_features: String,
pub middle_eastern_composer: bool,
/// Parameters from the last successfully applied New Guide Layout dialog.
pub guide_layout: Value,
}
fn default_guide_layout() -> Value {
json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false})
}
impl Default for ViewOptions {
@@ -112,6 +118,7 @@ impl Default for ViewOptions {
font_preview_size: "medium".into(),
language_features: "defaultFeatures".into(),
middle_eastern_composer: false,
guide_layout: default_guide_layout(),
}
}
}
@@ -846,7 +853,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]}),
),
"view.newGuideLayout" => {
dialog(app, json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}), json!({}))
let fields = app.ui.view.guide_layout.clone();
dialog(app, fields, json!({}))
}
"type.warpText" => {
let styles: Vec<&str> = std::iter::once("none").chain(photocraft_text::warp::STYLES.iter().map(|(_, s)| *s)).collect();
+40
View File
@@ -172,3 +172,43 @@ fn engine_commands_open_their_dialogs() {
crate::menus::invoke_unguarded(&mut app, &ctx, "file.closeAll", json!({})).unwrap();
assert!(app.ui.views.is_empty() && app.session.documents().is_empty());
}
#[test]
fn new_guide_layout_remembers_successful_values_not_cancelled_edits() {
let (mut app, ctx) = app_with(1);
let first = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["columns"], 8);
assert_eq!(app.ui.dialog_mut(first).unwrap().fields["gutter"], 20);
for (key, value) in [("columns", json!(2)), ("gutter", json!(0)), ("rows", json!(2)), ("rowGutter", json!(0))] {
app.ui.dialog_mut(first).unwrap().fields.insert(key.into(), value);
}
crate::dialogs::confirm(&mut app, first).unwrap();
let second = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &app.ui.dialog_mut(second).unwrap().fields;
assert_eq!(fields["columns"], 2);
assert_eq!(fields["gutter"], 0);
assert_eq!(fields["rows"], 2);
assert_eq!(fields["rowGutter"], 0);
assert_eq!(fields["clearExisting"], false);
// A cancelled edit must not replace the last successfully used values.
app.ui.dialog_mut(second).unwrap().fields.insert("columns".into(), json!(9));
app.ui.close_dialog(second);
let third = menu(&mut app, &ctx, "view.newGuideLayout", json!({})).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(third).unwrap().fields["columns"], 2);
// The remembered state is part of the serializable UI state.
let json_state = serde_json::to_value(&app.ui).unwrap();
assert_eq!(json_state["view"]["guide_layout"]["columns"], 2);
let restored: crate::state::UiState = serde_json::from_value(json_state).unwrap();
assert_eq!(restored.view.guide_layout["rows"], 2);
}
#[test]
fn new_guide_layout_does_not_remember_a_failed_command() {
let (mut app, _) = app_with(0);
let id = front(&mut app, "view.newGuideLayout", &json!({})).unwrap().unwrap()["dialog"].as_u64().unwrap();
app.ui.dialog_mut(id).unwrap().fields.insert("columns".into(), json!(3));
assert!(crate::dialogs::confirm(&mut app, id).is_err());
assert_eq!(app.ui.view.guide_layout["columns"], 8);
}
+5
View File
@@ -44,6 +44,11 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa
On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712).
Handled windowing and app initialization errors clear this launch's startup marker, restoring any
prior crash evidence. For example, a Linux launch without `DISPLAY` or `WAYLAND_DISPLAY` does not
change the next launch's graphics backend. Renderer initialization errors and driver crashes still
keep the marker for recovery.
If the device is lost while running (#243), every GPU entry point checks the device's health flag first, the canvas switches to the CPU compositor for the rest of the session and a notice says "GPU device was lost; using the CPU renderer." `ui.gpu.simulateLoss` triggers this path from the control channel.
## Environment variables
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 3 | 9 | 15 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 6 | 15 | 9 | |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 135 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 57 of 137 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
| [Reliability](#reliability) | 4 | 1 | 2 | never-crash lints on 27 of 27 crates; panic_hunt present |
@@ -154,7 +154,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 57 of 135** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 57 of 137** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
+3 -2
View File
@@ -76,8 +76,9 @@ draft open for correction. Settings marked for the next launch still require a r
## High DPI and 4K displays
Edit → Preferences → Interface → UI Scale applies immediately. Auto follows the operating
system's display scale (including fractional scales). For a 4K or larger monitor,
Auto uses at least 200% so text and controls remain readable. Detection uses the current monitor,
system's display scale, including fractional scales such as 125%, 150% and 175% (on Linux,
Wayland's fractional scale or X11's `Xft.dpi`). Only when the system reports no scaling (100%) on
a 4K or larger monitor does Auto use 200%, so text and controls remain readable. Detection uses the current monitor,
including portrait displays, and updates when the window moves between monitors. If monitor
size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) set an absolute UI
scale, allowing large 4K displays to use smaller controls when desired. Canvas zoom shortcuts
View File
Whitespace-only changes.
-5
View File
@@ -1,5 +0,0 @@
running 7 tests
.......
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.53s