Merge PR #1246
# Conflicts: # crates/pipeline/src/spots.rs # docs/parity-checklist.md
This commit is contained in:
commit
8df468bfc6
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
},
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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, .. }
|
||||
|
|
|
|||
|
|
@ -247,6 +247,11 @@ pub struct Photo {
|
|||
pub versions: Vec<Version>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<HistoryStep>,
|
||||
/// 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<usize>,
|
||||
/// 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<usize> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1079,15 +1079,47 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,11 +204,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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::<Vec<_>>(),
|
||||
"cursor": ph.history_index(),
|
||||
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -130,7 +130,8 @@ fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
|
|||
| 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;
|
||||
|
|
|
|||
|
|
@ -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<usize>) {
|
||||
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)));
|
||||
}
|
||||
|
|
@ -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<usize>) -> 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() {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue