collective

Browse / github.com/storytold/photocraft / patch

JPEG Options: remember the last quality and format (#9631)

JPEG Options: remember the last quality and format (Photoshop keeps them, also after a restart) ### What happens The JPEG Options dialog (#7883, shipped in v0.13.0) forgets what I chose last time. Every time it opens, it starts again from: - **Quality:** the opened JPEG's own quality, or, for a document that isn't an opened JPEG, the JPEG quality in Preferences › Export; - **Format Options:** always Baseline ("Standard"). So if I save a series of images as Progressive at quality 10, I have to set Progressive and 10 again for every file. The same happens after a restart. In the code this is `Start::for_active` in `crates/ui-egui/src/jpeg_options_ui.rs`: quality comes from `doc.file_en

Status
open
Score
0.00
Adopters
0 other operators
Author
SofiaAFigueredo (imported from pull request #9661, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+130 −8 in 2 files
Review
pending
Shared
2026-10-28
Id
e1aa69752036edc0378d73a4

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs crates/ui-egui/src/jpeg_options_ui.rs

diff --git a/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs b/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs--- a/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs+++ b/crates/ui-egui/src/discard_ui/tests/jpeg_tests.rs@@ -220,10 +220,11 @@ fn recorded_jpeg_saves_keep_their_options_and_replay_without_asking() {         steps,         &[             ("file.saveAs".into(), json!({"path": "p.jpg", "quality": 10, "jpegFormat": "optimized"})),-            ("file.saveACopy".into(), json!({"path": "copy.jpg", "quality": 6, "jpegFormat": "baseline"})),+            // The Save a Copy prompt starts at the format just chosen (it is remembered).+            ("file.saveACopy".into(), json!({"path": "copy.jpg", "quality": 6, "jpegFormat": "optimized"})),         ]     );-    let expected = [("p.jpg".to_string(), asked(pct(10), JpegFormat::Optimized)), ("copy.jpg".to_string(), asked(pct(6), JpegFormat::Baseline))];+    let expected = [("p.jpg".to_string(), asked(pct(10), JpegFormat::Optimized)), ("copy.jpg".to_string(), asked(pct(6), JpegFormat::Optimized))];     assert_eq!(writes.borrow().as_slice(), &expected);     writes.borrow_mut().clear();     let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap();@@ -270,3 +271,77 @@ fn a_save_can_choose_where_the_prompt_starts() {     assert_eq!(writes.borrow().as_slice(), &[("s.jpg".to_string(), asked(Some(70), JpegFormat::Progressive))]);     assert_eq!(h.state().session.actions.list[0].steps, [("file.saveAs".into(), json!({"path": "s.jpg", "jpegFormat": "progressive"}))]); }++/// The JPEG Options dialog starts at what it was last answered with (#9631): the quality and the+/// format, for a document that isn't an opened JPEG. Cancel remembers nothing.+#[test]+fn the_next_dialog_starts_at_the_last_answer() {+    let (mut h, writes) = jpeg_app("unused.jpg");+    let ctx = h.ctx.clone();+    h.state_mut().save_as(Some("a.jpg".into())).unwrap();+    h.run_steps(2);+    h.state_mut().jpeg_options.as_mut().unwrap().quality = 6;+    h.run_steps(2);+    h.get_by_label("Progressive").click();+    h.run_steps(2);+    h.get_by_label("Cancel").click();+    h.run_steps(3);+    assert!(writes.borrow().is_empty());+    h.state_mut().save_as(Some("b.jpg".into())).unwrap();+    h.run_steps(2);+    assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format)), Some((10, JpegFormat::Baseline)), "a cancelled dialog is not an answer");+    h.state_mut().jpeg_options.as_mut().unwrap().quality = 6;+    h.run_steps(2);+    h.get_by_label("Progressive").click();+    h.run_steps(2);+    h.get_by_label("OK").click();+    h.run_steps(3);+    crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap();+    h.run_steps(3);+    assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format, p.own_quality)), Some((6, JpegFormat::Progressive, None)));+    h.key_press(Key::Enter);+    h.run_steps(3);+    let expected = [("b.jpg".to_string(), asked(pct(6), JpegFormat::Progressive)), ("unused.jpg".to_string(), asked(pct(6), JpegFormat::Progressive))];+    assert_eq!(writes.borrow().as_slice(), &expected);+}++/// An opened JPEG still starts at its own quality (File › Save keeps it, #4032) but at the last+/// format; left on its own quality, the answer doesn't replace the quality remembered for other+/// documents.+#[test]+fn an_opened_jpeg_starts_at_its_own_quality_and_the_last_format() {+    let (mut h, _writes) = jpeg_app("unused.jpg");+    h.state_mut().session.prefs.edit(|p| {+        p.dialogs.insert("file.jpegOptions".into(), json!({"quality": 3, "jpegFormat": "progressive"}));+    });+    std::sync::Arc::make_mut(&mut h.state_mut().session.active_mut().unwrap().doc).file_encoding.jpeg_quality = Some(95);+    h.state_mut().save_as(Some("p.jpg".into())).unwrap();+    h.run_steps(2);+    assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format, p.own_quality)), Some((11, JpegFormat::Progressive, Some(95))));+    h.key_press(Key::Enter);+    h.run_steps(3);+    let saved = h.state().session.prefs().dialogs.get("file.jpegOptions").cloned();+    assert_eq!(saved, Some(json!({"quality": 3, "jpegFormat": "progressive"})), "the own quality is not an answer");+}++/// A remembered answer that no longer fits (a hand-edited preferences file) is ignored, value by+/// value.+#[test]+fn a_bad_remembered_answer_is_ignored() {+    for (saved, expected) in [+        (json!({"quality": 13, "jpegFormat": "progressive"}), (10, JpegFormat::Progressive)),+        (json!({"quality": "8", "jpegFormat": "fast"}), (10, JpegFormat::Baseline)),+        (json!({"quality": -1}), (10, JpegFormat::Baseline)),+        (json!({"quality": 2.5, "jpegFormat": 1}), (10, JpegFormat::Baseline)),+        (json!("not an object"), (10, JpegFormat::Baseline)),+        (json!({"quality": 0, "jpegFormat": "optimized"}), (0, JpegFormat::Optimized)),+    ] {+        let (mut h, _writes) = jpeg_app("unused.jpg");+        h.state_mut().session.prefs.edit(|p| {+            p.dialogs.insert("file.jpegOptions".into(), saved.clone());+        });+        h.state_mut().save_as(Some("p.jpg".into())).unwrap();+        h.run_steps(2);+        assert_eq!(h.state().jpeg_options.as_ref().map(|p| (p.quality, p.format)), Some(expected), "{saved}");+    }+}diff --git a/crates/ui-egui/src/jpeg_options_ui.rs b/crates/ui-egui/src/jpeg_options_ui.rs--- a/crates/ui-egui/src/jpeg_options_ui.rs+++ b/crates/ui-egui/src/jpeg_options_ui.rs@@ -3,7 +3,9 @@ //! 5–7, High 8–9, Maximum 10–12), mapped to the encoder's 1–100 by //! `photocraft_engine::file_cmds::jpeg_quality`; Format Options are Baseline ("Standard"), Baseline //! Optimized and Progressive. Matte, Scans and the size preview aren't offered yet. Re-saving an-//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032).+//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). Otherwise the+//! dialog starts at the quality and format it was last answered with, also after a restart, as+//! Photoshop's does (see [`Start::for_active`]). //! //! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written //! once answered. Cancel drops the save. Scripts skip the prompt by passing `quality` or@@ -135,14 +137,58 @@ impl Start {         Start { quality: quality_from_percent(quality), own_quality: None, format: JpegFormat::Baseline }     } -    /// For the active document: its opened JPEG's own quality (`Document::file_encoding`), or-    /// else the Export preferences' JPEG quality (the one Quick Export uses).+    /// For the active document: the Format Options the dialog was last answered with, else+    /// Baseline; and the quality of its opened JPEG (`Document::file_encoding`, which File › Save+    /// keeps, #4032), else the quality last answered, else the Export preferences' JPEG quality+    /// (the one Quick Export uses).     pub fn for_active(app: &PhotocraftApp) -> Self {-        match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) {+        let remembered = remembered(app);+        let start = match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) {             Some(q) => Start::own(q),-            None => Start::percent(app.session.prefs().export.jpeg_quality),-        }+            None => match remembered.quality {+                Some(quality) => Start { quality, own_quality: None, format: JpegFormat::Baseline },+                None => Start::percent(app.session.prefs().export.jpeg_quality),+            },+        };+        Start { format: remembered.format.unwrap_or(start.format), ..start }+    }+}++/// Preferences ▸ remembered dialog settings: JPEG Options writes its last answer under this key+/// and starts the next dialog from it, as Photoshop does (it keeps it across restarts). There is+/// no command of this id; the key only names the dialog among the others.+const REMEMBER_KEY: &str = "file.jpegOptions";++/// What the dialog was last answered with; a value that no longer fits (a hand-edited+/// preferences file) is left out.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+struct Remembered {+    quality: Option<u8>,+    format: Option<JpegFormat>,+}++fn remembered(app: &PhotocraftApp) -> Remembered {+    let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(REMEMBER_KEY) else { return Remembered::default() };+    Remembered {+        quality: saved.get("quality").and_then(Value::as_u64).and_then(|q| u8::try_from(q).ok()).filter(|&q| q <= MAX_QUALITY),+        format: saved.get("jpegFormat").and_then(Value::as_str).and_then(JpegFormat::parse),+    }+}++/// Records an answered dialog for the next one. A quality left on the opened file's own is not+/// an answer: the earlier one stays, so saving a series of opened JPEGs doesn't forget what a+/// new document was last saved at.+fn remember(app: &mut PhotocraftApp, choice: JpegChoice) {+    let before = remembered(app);+    let quality = choice.quality.or(before.quality);+    let mut saved = serde_json::Map::new();+    if let Some(q) = quality {+        saved.insert("quality".into(), q.into());     }+    saved.insert("jpegFormat".into(), choice.format.name().into());+    app.session.prefs.edit(|p| {+        p.dialogs.insert(REMEMBER_KEY.into(), Value::Object(saved));+    }); }  /// A save waiting on the JPEG Options answer.@@ -250,6 +296,7 @@ pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) {     // quality out, so a replay keeps the file's own quality too, as File › Save does (#4032).     let own = p.own_quality.filter(|&q| quality_from_percent(u32::from(q)) == p.quality);     let choice = JpegChoice { quality: own.is_none().then_some(p.quality), format: p.format };+    remember(app, choice);     let step = if p.record == "file.save" {         // A plain Save writes whichever file the action is played on (#3531), with the answer.         plain_save_step(Some(choice))