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
@@ -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}");+ }+}@@ -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))