# Conflicts:
#	crates/pipeline/src/spots.rs
#	docs/parity-checklist.md
This commit is contained in:
bflatastic 2026-10-13 11:05:44 +07:00
commit 8df468bfc6
11 changed files with 249 additions and 17 deletions

View File

@ -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)]

View File

@ -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) {

View File

@ -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, .. }

View File

@ -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,

View File

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

View File

@ -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())
}
}

View File

@ -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<_>>(),
}))
}),

View File

@ -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;

View File

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

View File

@ -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() {

View File

@ -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 |