Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481) (#11558)

* Export to files: a Custom JPEG quality in Artboards, Layers and Layer Comps to Files (#11481)

The engine commands already took `quality` (0..12), but their dialogs
offered no way to set it. Each dialog now has a Quality row with a Custom
checkbox, shared with Batch: quality is sent only for JPEG with Custom on,
so an export left alone writes exactly what it wrote before (the source
JPEG's own quality, else the encoder default), and PNG, WebP (still
lossless), PSD, TIFF and BMP never get one. Alt+Reset restores the Custom
state too. The fields follow the reference dialog's order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tests: the export quality tests go through the dialog's own controls (#11481)

Custom and Quality are now ticked and typed through their widgets, a new
test switches one dialog from JPEG to PNG and WebP and back and checks the
Custom quality survives, and Alt+Reset is clicked through the same path the
app uses instead of calling reset_form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Zinadin ZidanandClaude Opus 5.5 authored and GitHub committed 2026-11-06 02:10:18 +01:00
1 parent 9bef3b2dc8
commit 9b02eac2c4
3 files changed
+286 -11

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
//! #11481: File › Export › Artboards to Files (and Layers / Layer Comps to Files) has a JPEG
//! Quality field behind a Custom checkbox, as Batch has (#3956). The quality reaches the command
//! only for a JPEG with Custom on: left alone, an export writes what the command writes with no
//! quality (the source JPEG's own quality, else the encoder default), a WebP stays lossless and
//! PNG has none.
use crate::PhotocraftApp;
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::{Value, json};
const ARTBOARDS: &str = "file.export.artboardsToFiles";
const EXPORTS: [&str; 3] = [ARTBOARDS, "file.export.layersToFiles", "file.export.layerCompsToFiles"];
fn harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(photocraft_engine::Session::new(), Default::default())
});
let app = h.state_mut();
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
// A noisy layer inside the board, so the JPEG quality shows in the file.
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color": "#8A42B3"})).unwrap();
app.run("filter.noise.addNoise", json!({"amount": 60})).unwrap();
app.run("layer.new.artboardFromLayers", json!({"name": "Board"})).unwrap();
app.run("layerComp.new", json!({"name": "Comp"})).unwrap();
app.sync_views();
h
}
fn open(h: &mut Harness<'_, PhotocraftApp>, id: &str, dir: &str, format: &str) -> u64 {
let ctx = h.ctx.clone();
let dialog = crate::menus::invoke(h.state_mut(), &ctx, id, json!({})).unwrap()["dialog"].as_u64().unwrap();
let fields = &mut h.state_mut().ui.dialog_mut(dialog).unwrap().fields;
fields.insert("dir".into(), json!(dir));
fields.insert("prefix".into(), json!("out"));
fields.insert("format".into(), json!(format));
h.run_steps(4);
dialog
}
fn params(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Value {
crate::filter_dialog::params_of(&h.state_mut().ui.dialog_mut(dialog).unwrap().fields)
}
fn exported(h: &mut Harness<'_, PhotocraftApp>, dialog: u64) -> Vec<u8> {
let r = crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
}
/// The same export run straight through the command, into `dir`.
fn direct(h: &mut Harness<'_, PhotocraftApp>, dir: &str, format: &str, quality: Option<i64>) -> Vec<u8> {
let mut p = json!({"dir": dir, "prefix": "out", "format": format});
if let Some(q) = quality {
p["quality"] = json!(q);
}
let r = h.state_mut().run(ARTBOARDS, p).unwrap();
std::fs::read(r["files"][0].as_str().unwrap()).unwrap()
}
/// Whether the Quality number field (not its row label) takes input.
fn quality_enabled(h: &Harness<'_, PhotocraftApp>) -> bool {
let fields: Vec<bool> =
h.query_all_by_label("Quality").filter(|n| n.accesskit_node().role() != Role::Label).map(|n| !n.accesskit_node().is_disabled()).collect();
assert!(!fields.is_empty(), "the dialog shows a Quality field");
assert!(fields.iter().all(|e| *e == fields[0]), "{fields:?}");
fields[0]
}
/// Whether the Custom checkbox is ticked.
fn custom_ticked(h: &Harness<'_, PhotocraftApp>) -> bool {
h.get_by_role_and_label(Role::CheckBox, "Custom").accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)
}
/// The value the Quality field shows.
fn shown_quality(h: &Harness<'_, PhotocraftApp>) -> Option<f64> {
h.get_by_role_and_label(Role::SpinButton, "Quality").accesskit_node().numeric_value()
}
fn tick_custom(h: &mut Harness<'_, PhotocraftApp>) {
h.get_by_role_and_label(Role::CheckBox, "Custom").click();
h.run_steps(2);
}
/// Types `text` into the Quality field and commits it with Tab, as a user does.
fn type_quality(h: &mut Harness<'_, PhotocraftApp>, text: &str) {
h.get_by_role_and_label(Role::SpinButton, "Quality").click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
for ch in text.chars() {
h.event(egui::Event::Text(ch.to_string()));
h.run_steps(1);
}
h.key_press(egui::Key::Tab);
h.run_steps(2);
}
/// Holds Alt, which turns Cancel into Reset, and clicks it.
fn alt_reset(h: &mut Harness<'_, PhotocraftApp>) {
let alt = egui::Modifiers::ALT;
h.event(egui::Event::ModifiersChanged(alt));
h.run_steps(2);
let at = h.get_by_label("Reset").rect().center();
h.event(egui::Event::PointerMoved(at));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: alt });
h.run_steps(1);
}
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
h.run_steps(3);
}
fn tmp() -> (tempfile::TempDir, String) {
let t = tempfile::tempdir().unwrap();
let s = t.path().to_string_lossy().into_owned();
(t, s)
}
#[test]
fn artboards_to_files_sends_a_custom_jpeg_quality() {
let mut h = harness();
let (_a, via_dialog) = tmp();
let (_b, via_command) = tmp();
let mut sizes = Vec::new();
for quality in [0, 3, 12] {
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
assert!(!custom_ticked(&h) && !quality_enabled(&h), "JPEG with Custom off: the field is greyed out");
tick_custom(&mut h);
assert!(custom_ticked(&h) && quality_enabled(&h), "JPEG with Custom on: the field takes input");
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "Custom starts at quality 10");
type_quality(&mut h, &quality.to_string());
assert_eq!(shown_quality(&h), Some(quality as f64), "the field shows the typed quality");
assert_eq!(params(&mut h, dialog)["quality"], json!(quality), "the typed quality reaches the command");
let bytes = exported(&mut h, dialog);
assert!(bytes == direct(&mut h, &via_command, "jpg", Some(quality)), "quality {quality}: the file the command writes at that quality");
sizes.push(bytes.len());
}
assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2], "a higher quality writes a bigger JPEG: {sizes:?}");
}
#[test]
fn an_untouched_jpeg_export_writes_what_the_command_writes_with_no_quality() {
let mut h = harness();
let (_a, via_dialog) = tmp();
let (_b, via_command) = tmp();
let dialog = open(&mut h, ARTBOARDS, &via_dialog, "jpg");
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "no quality is sent");
let bytes = exported(&mut h, dialog);
assert!(bytes == direct(&mut h, &via_command, "jpg", None), "the untouched dialog keeps the command's own JPEG quality");
}
#[test]
fn export_to_files_quality_leaves_png_and_webp_as_they_were() {
let mut h = harness();
let (_a, dir) = tmp();
for id in EXPORTS {
for format in ["png", "webp"] {
let dialog = open(&mut h, id, &dir, format);
// Custom on from an earlier JPEG choice still sends nothing for these formats.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
h.run_steps(2);
assert!(!quality_enabled(&h), "{id} {format}: the field is greyed out");
assert!(params(&mut h, dialog).get("quality").is_none_or(Value::is_null), "{id} {format}: no quality is sent");
let bytes = exported(&mut h, dialog);
if format == "webp" {
// The default WebP stays lossless (a VP8L bitstream, not lossy VP8).
assert!(bytes.windows(4).any(|w| w == b"VP8L"), "{id}: the WebP stays lossless");
}
}
// Back to JPEG with Custom on, the field sends its quality again.
let dialog = open(&mut h, id, &dir, "jpg");
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("__jpegCustom".into(), json!(true));
h.run_steps(2);
assert!(quality_enabled(&h), "{id}");
assert_eq!(params(&mut h, dialog)["quality"], json!(10), "{id}");
crate::dialogs::cancel(h.state_mut(), dialog).unwrap();
}
}
/// A Custom quality survives a switch to another format and back in the same dialog.
#[test]
fn the_custom_quality_survives_a_format_round_trip() {
let mut h = harness();
let (_a, dir) = tmp();
let dialog = open(&mut h, ARTBOARDS, &dir, "jpg");
tick_custom(&mut h);
type_quality(&mut h, "4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
for format in ["png", "webp"] {
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!(format));
h.run_steps(2);
assert!(!quality_enabled(&h), "{format}: the field is greyed out");
assert!(params(&mut h, dialog)["quality"].is_null(), "{format}: no quality is sent");
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(2);
assert!(custom_ticked(&h) && quality_enabled(&h), "back from {format}: Custom is still on");
assert_eq!(shown_quality(&h), Some(4.0), "back from {format}: the field still shows 4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4), "back from {format}: quality 4 is sent");
}
}
/// Alt+Reset (Alt held, Cancel clicked as Reset) puts the Custom checkbox and its quality back to
/// how the dialog opened, and keeps the dialog open.
#[test]
fn alt_reset_restores_the_custom_quality() {
let mut h = harness();
let ctx = h.ctx.clone();
let dialog = crate::menus::invoke(h.state_mut(), &ctx, ARTBOARDS, json!({})).unwrap()["dialog"].as_u64().unwrap();
// JPEG chosen in the dialog, after it opened: Reset puts the format back too.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(4);
tick_custom(&mut h);
type_quality(&mut h, "4");
assert_eq!(params(&mut h, dialog)["quality"], json!(4));
alt_reset(&mut h);
assert!(h.state().ui.dialogs.iter().any(|d| d.id == dialog), "Reset keeps the dialog open");
let p = params(&mut h, dialog);
assert_eq!((&p["format"], &p["quality"]), (&json!("png"), &Value::Null), "the opening format, no quality");
assert!(!custom_ticked(&h) && !quality_enabled(&h), "Custom is off again");
assert_eq!(shown_quality(&h), Some(10.0), "the field shows the opening quality");
// Choosing JPEG again still sends no quality until Custom is ticked.
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("format".into(), json!("jpg"));
h.run_steps(2);
assert!(!custom_ticked(&h), "JPEG after Reset: Custom stays off");
assert!(params(&mut h, dialog)["quality"].is_null(), "JPEG after Reset: no quality is sent");
}
+2
View File
@@ -927,6 +927,8 @@ mod window_frame_tests;
#[cfg(test)]
mod browse_field_tests;
#[cfg(test)]
mod export_quality_tests;
#[cfg(test)]
mod options_hit_area_tests;
#[cfg(test)]
mod select_and_mask_button_tests;
+54 -11
View File
@@ -66,8 +66,10 @@ pub(super) fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields:
if let Value::Object(m) = fields {
f.extend(m);
}
// Snapshot user-facing params at open so Alt+Reset can restore them.
let orig: Map<String, Value> = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
// Snapshot user-facing params at open so Alt+Reset can restore them, with the Custom
// quality rows' inactive state.
let orig: Map<String, Value> =
f.iter().filter(|(k, _)| !k.starts_with("__") || RESET_SHELL_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
f.insert("__formOrig".into(), Value::Object(orig));
app.ui.open_dialog(DialogKind::Command, f)
}
@@ -166,18 +168,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") {
ui.label(label(&k));
let mut custom = v.as_f64().is_some();
let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32;
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, &mut custom, tl!("Custom"));
ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality")));
});
let mut quality = v.as_f64().or_else(|| f.get(BATCH_QUALITY).and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32;
custom_quality_row(ui, &mut custom, &mut quality, true);
// None preserves the command's existing JPEG and lossless WebP defaults.
// The inactive custom value is shell state, never a command parameter.
f.insert("__batchQuality".into(), json!(quality));
f.insert(BATCH_QUALITY.into(), json!(quality));
f.insert(k, if custom { json!(quality) } else { Value::Null });
ui.end_row();
continue;
}
if k == "quality" && f.contains_key(JPEG_QUALITY) {
ui.label(label(&k));
jpeg_quality_field(ui, f);
ui.end_row();
continue;
}
let new = match &v {
Value::Array(_) | Value::Object(_) | Value::Null => continue,
Value::Bool(b) => {
@@ -246,6 +251,42 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
camera_data(ui, f);
}
/// Batch's inactive Custom quality (#3956): shell state, never a command parameter.
const BATCH_QUALITY: &str = "__batchQuality";
/// An export-to-files dialog's Custom checkbox and its quality (#11481), kept while the format
/// is not JPEG or Custom is off: shell state, never command parameters.
const JPEG_CUSTOM: &str = "__jpegCustom";
const JPEG_QUALITY: &str = "__jpegQuality";
/// The shell keys Alt+Reset restores along with a form's params.
const RESET_SHELL_KEYS: [&str; 3] = [BATCH_QUALITY, JPEG_CUSTOM, JPEG_QUALITY];
/// A Custom checkbox and the 0..12 Quality field it enables (Batch, the exports to files). Off,
/// the command gets no quality and keeps its own defaults; `usable` false greys out both.
fn custom_quality_row(ui: &mut egui::Ui, custom: &mut bool, quality: &mut f32, usable: bool) {
ui.add_enabled_ui(usable, |ui| {
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, custom, tl!("Custom"));
ui.add_enabled_ui(*custom, |ui| crate::widgets::value_field(ui, quality, 0.0..=12.0, "", 74.0).accessible_name(tl!("Quality")));
});
});
}
/// Layers, Layer Comps and Artboards to Files: the JPEG quality, sent only for a JPEG with Custom
/// on. Left alone, the export writes what the command writes with no quality (the source JPEG's
/// own quality, else the encoder default), as Batch does; a quality would also make a WebP lossy
/// (its default is lossless), and PNG, PSD, TIFF and BMP have none.
fn jpeg_quality_field(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let jpeg = f.get("format").and_then(Value::as_str).is_some_and(|x| x.eq_ignore_ascii_case("jpg") || x.eq_ignore_ascii_case("jpeg"));
let sent = f.get("quality").and_then(Value::as_f64);
let mut custom = sent.is_some() || f.get(JPEG_CUSTOM).and_then(Value::as_bool).unwrap_or(false);
let mut quality = sent.or_else(|| f.get(JPEG_QUALITY).and_then(Value::as_f64)).unwrap_or(10.0).clamp(0.0, 12.0) as f32;
custom_quality_row(ui, &mut custom, &mut quality, jpeg);
let quality = quality.round().clamp(0.0, 12.0) as i64;
f.insert(JPEG_CUSTOM.into(), json!(custom));
f.insert(JPEG_QUALITY.into(), json!(quality));
f.insert("quality".into(), if jpeg && custom { json!(quality) } else { Value::Null });
}
/// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP).
fn camera_data(ui: &mut egui::Ui, f: &Map<String, Value>) {
let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return };
@@ -428,7 +469,8 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para
let (_, _, name) = doc?;
dialog(
app,
json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true, "trim": false}),
json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "quality": null, JPEG_CUSTOM: false,
JPEG_QUALITY: 10, "visibleOnly": true, "trim": false, "__order": ["dir", "prefix", "visibleOnly", "format", "quality", "trim"]}),
json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}),
)
}
@@ -436,9 +478,10 @@ pub(super) fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, para
let (_, _, name) = doc?;
let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string();
let fields = if id == "file.export.layerCompsToFiles" {
json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false})
json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "selectedOnly": false,
"__order": ["dir", "prefix", "selectedOnly", "format", "quality"]})
} else {
json!({"dir": dir, "prefix": prefix, "format": "png"})
json!({"dir": dir, "prefix": prefix, "format": "png", "quality": null, JPEG_CUSTOM: false, JPEG_QUALITY: 10, "__order": ["dir", "prefix", "format", "quality"]})
};
dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}))
}