diff --git a/crates/ui-egui/src/export_quality_tests.rs b/crates/ui-egui/src/export_quality_tests.rs new file mode 100644 index 0000000000..1f1bfd88ae --- /dev/null +++ b/crates/ui-egui/src/export_quality_tests.rs @@ -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 { + 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) -> Vec { + 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 = + 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 { + 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"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7a5f226a5..783b952cd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menus/view_cmds/dialogs.rs b/crates/ui-egui/src/menus/view_cmds/dialogs.rs index 57ade54a13..16cefbd87b 100644 --- a/crates/ui-egui/src/menus/view_cmds/dialogs.rs +++ b/crates/ui-egui/src/menus/view_cmds/dialogs.rs @@ -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 = 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 = + 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) { 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) { 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) { + 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) { 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"]})) }