diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 697ddf01..dd92b5e2 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -50,6 +50,7 @@ //! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | +//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -74,7 +75,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 7; +pub const VERSION: u32 = 8; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cc24266f..ecde973b 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -125,6 +125,12 @@ pub enum Op { id: PhotoId, step: HistoryStep, }, + /// Move the History cursor (see [`Photo::history_cursor`]) without changing the steps. + SetHistoryCursor { + id: PhotoId, + #[serde(default)] + cursor: Option, + }, AddAlbum { album: Album, }, @@ -545,12 +551,25 @@ impl Catalog { Op::PushHistory { id, step } => { let p = self.photo_mut(id)?; let old = p.history.clone(); + let old_cursor = p.history_cursor.take(); + // a new edit made from an earlier step drops the steps after it + if let Some(c) = old_cursor { + p.history.truncate(c.saturating_add(1)); + } p.history.push(step); if p.history.len() > HISTORY_LIMIT { let n = p.history.len() - HISTORY_LIMIT; p.history.drain(..n); } - Op::SetHistory { id, history: old } + if old_cursor.is_none() { + Op::SetHistory { id, history: old } + } else { + Op::Batch { ops: vec![Op::SetHistory { id, history: old }, Op::SetHistoryCursor { id, cursor: old_cursor }] } + } + } + Op::SetHistoryCursor { id, cursor } => { + let p = self.photo_mut(id)?; + Op::SetHistoryCursor { id, cursor: std::mem::replace(&mut p.history_cursor, cursor) } } Op::AddAlbum { album } => { if self.albums.contains_key(&album.id) { diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index ff83a179..9cfc14f3 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -279,6 +279,7 @@ impl Op { | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } | Op::SetCaptured { id, .. } | Op::SetAnalysis { id, .. } | Op::SetFile { id, .. } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 6ceee798..434ea3a1 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -247,6 +247,11 @@ pub struct Photo { pub versions: Vec, #[serde(default)] pub history: Vec, + /// The History step the photo's settings are at, when it isn't the latest one (Go to History + /// Step moved back): later steps stay listed and can be returned to until a new edit drops + /// them. `None` (and catalogs written before the cursor existed) = the latest step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub history_cursor: Option, /// In "Recently Deleted". #[serde(default)] pub deleted: bool, @@ -328,6 +333,12 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { } impl Photo { + /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). + pub fn history_index(&self) -> Option { + let last = self.history.len().checked_sub(1)?; + Some(self.history_cursor.map_or(last, |c| c.min(last))) + } + pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { id, @@ -348,6 +359,7 @@ impl Photo { edited: None, versions: Vec::new(), history: Vec::new(), + history_cursor: None, deleted: false, local: false, duration: None, diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 37bd639e..e2461781 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -211,8 +211,9 @@ fn op_variants_are_versioned() { Op::SetKeyword { .. } => 5, Op::SetFolderRecord { .. } => 6, Op::SetAsShotWb { .. } => 7, + Op::SetHistoryCursor { .. } => 8, } } - let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None }); + let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 42f59fe7..1ae72e48 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -1079,15 +1079,47 @@ pub fn specs() -> Vec { let id = active(s, "history.clear")?; let d = s.develop_of(id).unwrap_or_default(); let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }]; - s.commit("Clear History", Op::SetHistory { id, history })?; + s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?; ok() }), - cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| { - let id = active(s, "history.restore")?; - let i = f64_req(p, "index", "history.restore")? as usize; - let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?; - s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?; - ok() + cmd!( + "history.restore", + "Go to History Step", + [], + None, + "{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit", + has_active, + |s, p| { + let c = "history.restore"; + let id = active(s, c)?; + let i = f64_req(p, "index", c)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(c, "no such step")); + } + go_to_history_step(s, c, id, i as usize) + } + ), + cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| { + let c = "history.previous"; + let id = active(s, c)?; + let i = s + .catalog + .photo(id) + .and_then(|ph| ph.history_index()) + .and_then(|i| i.checked_sub(1)) + .ok_or_else(|| bad(c, "at the first History step"))?; + go_to_history_step(s, c, id, i) + }), + cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| { + let c = "history.next"; + let id = active(s, c)?; + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let i = ph + .history_index() + .map(|i| i.saturating_add(1)) + .filter(|i| *i < ph.history.len()) + .ok_or_else(|| bad(c, "at the latest History step"))?; + go_to_history_step(s, c, id, i) }), // ---- interactions (slider drags, brush strokes) cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| { @@ -1104,3 +1136,32 @@ pub fn specs() -> Vec { }), ] } + +/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later +/// steps stay listed (one undo step). +fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result { + let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; + let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?; + let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i); + let label = format!("History: {}", st.label); + let edited = Some((s.clock)()); + s.commit( + &label, + Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] }, + )?; + ok() +} + +fn has_history_before(s: &Session) -> std::result::Result<(), String> { + let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index()); + if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) } +} + +fn has_history_after(s: &Session) -> std::result::Result<(), String> { + let photo = s.active().and_then(|id| s.catalog.photo(id)); + if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) { + Ok(()) + } else { + Err("at the latest History step".into()) + } +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 30c35b7e..2e0b6f15 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -204,11 +204,12 @@ pub fn specs() -> Vec { Ok(Value::Array(builtin.chain(imported).collect())) }), cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), - cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { + cmd!(query "history.list", "List History", [], None, "{id?} — returns {history: labels oldest first, cursor: index of the step the settings are at (later steps can be returned to), versions}", has_active, |s, p| { let id = photo_arg(s, p, "history.list")?; let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?; Ok(json!({ "history": ph.history.iter().map(|h| h.label.clone()).collect::>(), + "cursor": ph.history_index(), "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a024eb02..04cc5135 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -130,7 +130,8 @@ fn single_photo(op: &Op, depth: usize) -> Option { | Op::SetMeta { id, .. } | Op::SetVersions { id, .. } | Op::SetHistory { id, .. } - | Op::PushHistory { id, .. } => Some(*id), + | Op::PushHistory { id, .. } + | Op::SetHistoryCursor { id, .. } => Some(*id), Op::Batch { ops } if depth < 8 => { let mut ids = ops.iter().map(|o| single_photo(o, depth + 1)); let first = ids.next()??; @@ -1005,6 +1006,8 @@ mod tests_folders; #[cfg(test)] mod tests_forget_local; #[cfg(test)] +mod tests_history_cursor; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs new file mode 100644 index 00000000..55c26540 --- /dev/null +++ b/crates/engine/src/tests_history_cursor.rs @@ -0,0 +1,100 @@ +//! Feature: the History panel has a cursor. Going to a step loads its settings without adding a +//! step; later steps stay listed until a new edit drops them; undo/redo stay coherent. + +use serde_json::json; + +use crate::Session; + +fn exposure(s: &Session) -> f64 { + s.develop_of(s.active().unwrap()).unwrap().light.exposure +} + +fn list(s: &mut Session) -> (usize, Option) { + let h = s.execute("history.list", &json!({})).unwrap(); + (h["history"].as_array().unwrap().len(), h["cursor"].as_u64().map(|c| c as usize)) +} + +/// A demo session with three exposure edits (1, 2, 3) as its latest History steps. +fn three_edits() -> (Session, usize) { + let mut s = Session::with_demo(); + for v in [1.0, 2.0, 3.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, cursor) = list(&mut s); + assert_eq!(cursor, Some(n - 1), "the cursor starts at the latest step"); + (s, n) +} + +#[test] +fn going_back_adds_no_step_and_forward_returns() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + assert_eq!(exposure(&s), 1.0); + assert_eq!(list(&mut s), (n, Some(n - 3)), "no new step; later steps kept"); + s.execute("history.next", &json!({})).unwrap(); + assert_eq!(exposure(&s), 2.0); + s.execute("history.restore", &json!({"index": n - 1})).unwrap(); + assert_eq!(exposure(&s), 3.0); + assert_eq!(list(&mut s), (n, Some(n - 1))); + assert!(s.execute("history.next", &json!({})).is_err(), "nothing after the latest step"); + s.execute("history.previous", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (n, Some(n - 2)))); + assert!(s.execute("history.restore", &json!({"index": n})).is_err()); + assert!(s.execute("history.restore", &json!({"index": -1})).is_err()); +} + +#[test] +fn a_new_edit_behind_the_end_drops_the_later_steps() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -1.0})).unwrap(); + assert_eq!(list(&mut s), (n - 1, Some(n - 2)), "steps 2 and 3 dropped, the new one appended"); + assert_eq!(exposure(&s), -1.0); + // undo brings the dropped steps and the cursor back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (-1.0, (n - 1, Some(n - 2)))); +} + +#[test] +fn undo_and_redo_move_the_cursor_with_the_settings() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 3})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (3.0, (n, Some(n - 1))), "undoing a step move"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); +} + +#[test] +fn clearing_history_resets_the_cursor() { + let (mut s, n) = three_edits(); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + s.execute("history.clear", &json!({})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (2.0, (1, Some(0)))); + assert_eq!(s.catalog.photo(s.active().unwrap()).unwrap().history_cursor, None); +} + +#[test] +fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() { + let mut s = Session::with_demo(); + let snap = s.catalog.to_snapshot(); + let _ = s.drain_log(); + for v in [1.0, 2.0] { + s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap(); + } + let (n, _) = list(&mut s); + s.execute("history.restore", &json!({"index": n - 2})).unwrap(); + let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); + let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap(); + c.replay(&log).unwrap(); + assert_eq!(c.to_snapshot(), s.catalog.to_snapshot()); + let id = s.active().unwrap(); + assert_eq!(c.photo(id).unwrap().history_cursor, Some(n - 2)); + // a photo written before the cursor existed has no `history_cursor` key: at the latest step + let mut v = serde_json::to_value(c.photo(id).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("history_cursor"); + let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap(); + assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1))); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 12c26d3b..8d0cc0d3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1108,6 +1108,23 @@ fn short_time(iso: &str) -> String { } } +/// How a History row is drawn: the step the settings are at is highlighted, the steps after it +/// (still there to return to until a new edit) are greyed out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HistoryRow { + Done, + Current, + Undone, +} + +fn history_row(i: usize, cursor: Option) -> HistoryRow { + match cursor { + Some(c) if i == c => HistoryRow::Current, + Some(c) if i > c => HistoryRow::Undone, + _ => HistoryRow::Done, + } +} + fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "History"); @@ -1116,19 +1133,24 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if p.history.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim)); } + let cursor = p.history_index(); for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); register(ui.ctx(), format!("history:{i}"), r); - if resp.hovered() { + let row = history_row(i, cursor); + if row == HistoryRow::Current { + ui.painter().rect_filled(r, 3.0, t.pressed); + } else if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon); + let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) }; + paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon); ui.painter().text( pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::history_label(&h.label, &app.session.presets), t.font(12.5), - t.text_label, + text, ); if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); @@ -1152,6 +1174,16 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { #[cfg(test)] mod tests { + // Feature: the History panel greys out the steps after the cursor + #[test] + fn history_rows_after_the_cursor_are_greyed() { + use super::{HistoryRow, history_row}; + let rows: Vec<_> = (0..4).map(|i| history_row(i, Some(1))).collect(); + assert_eq!(rows, [HistoryRow::Done, HistoryRow::Current, HistoryRow::Undone, HistoryRow::Undone]); + assert_eq!(history_row(3, Some(3)), HistoryRow::Current, "at the latest step nothing is greyed"); + assert_eq!(history_row(0, None), HistoryRow::Done); + } + // Feature: the aspect button tells what is locked #[test] fn aspect_label_names_presets_in_either_orientation() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index f19eff3d..25273143 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -342,7 +342,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | | LR-VER-PANEL | Versions panel | P1 | ✅ | `cmd:panel.versions`, `cmd:version.create`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update`, `crates/ui-egui/src/panels/right.rs` (`versions`) | Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) | | LR-VER-AUTO | Automatic versions | P2 | ✅ | `crates/engine/src/lib.rs` (`auto_version`) | leaving an edited photo keeps its settings as an Auto version (when they differ from the latest version; 20 kept; not an undo step); Versions panel ▸ Auto | -| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | +| LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next` | going to a step moves a per-photo cursor without adding a step; later steps stay listed (greyed) until a new edit drops them; `history.list` returns `cursor` | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | | ## N. Copy / paste / sync (SYNC) @@ -726,7 +726,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | -| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | +| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.previous`, `cmd:history.next`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history; clicking a step loads it without adding one (current step highlighted, later steps greyed and returnable until a new edit truncates them; one undo step; cursor saved in the catalog, old catalogs open at the latest step) | | LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). printer / paper ICC profiles can be loaded as the proof profile (size-capped, the last few kept), with rendering intent (Perceptual / Relative Colorimetric), Simulate Paper & Ink and a device-gamut warning (`crates/codecs/src/proof.rs`, `crates/engine/src/proof.rs`) | | LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner | | LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's | @@ -814,6 +814,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | +| 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |