//! Named snapshots and the undo/redo stack. use dr_pipeline::Edit; use crate::labels; use super::session::DevelopSession; impl DevelopSession { // ---- named snapshots --------------------------------------------------- /// TRACES: FR-DEV-5 /// Hand the session the snapshots its sidecar holds. Called once, on /// open, beside [`Self::apply_version`]. pub fn set_snapshots(&mut self, snapshots: Vec) { self.snapshots = snapshots; self.removed_snapshots.clear(); } /// The snapshots as they stand, oldest first. pub fn snapshots(&self) -> &[dr_pipeline::Version] { &self.snapshots } /// The ids deleted this sitting, for the save. pub fn removed_snapshots(&self) -> &[String] { &self.removed_snapshots } /// TRACES: FR-DEV-5 /// Name the state the photograph is in, and keep it. Returns the id. /// /// Not a history step: taking a snapshot changes nothing about the edit, /// and an undo that removed one would be undoing a decision to remember /// rather than a change to the photograph. Deleting one is the same. /// /// The id is stamped with the second and a per-process random word /// rather than counted, because two devices can each take a snapshot of /// the same photograph and both have to survive the merge — which keys /// on this id, and would fold two `snap-3`s into one. pub fn take_snapshot(&mut self, name: &str) -> String { use std::hash::{BuildHasher, Hasher}; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let salt = std::collections::hash_map::RandomState::new() .build_hasher() .finish(); let id = format!("snap-{now}-{:08x}", salt as u32); let name = name.trim(); let name = if name.is_empty() { format!("Snapshot {}", self.snapshots.len() + 1) } else { name.to_string() }; let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph); // The stack with the model's coverage folded in, for the reason the // save uses it: a subject layer stored by identity alone renders as // nothing until a model is run, and a snapshot restored on the other // device, or in a batch export, never gets one. version.masks = self.masks_for_storage(); version.modified = now; self.snapshots.push(version); id } /// TRACES: FR-DEV-5 /// Put the photograph back the way a snapshot has it. One history step, /// so it is undoable as a whole, exactly as a paste is. pub fn restore_snapshot(&mut self, id: &str) -> bool { let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else { return false; }; let rebake = version.apply(&mut self.graph); self.pay_film_debt(&rebake); self.history .record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED)); true } /// TRACES: FR-DEV-5 pub fn rename_snapshot(&mut self, id: &str, name: &str) { let name = name.trim(); if name.is_empty() { return; } if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) { v.name = name.to_string(); } } /// TRACES: FR-DEV-5 /// Forget a snapshot. Remembered as a deletion so the save takes it out /// of the file rather than merely not putting it back. pub fn delete_snapshot(&mut self, id: &str) { let before = self.snapshots.len(); self.snapshots.retain(|v| v.uuid != id); if self.snapshots.len() != before { self.removed_snapshots.push(id.to_string()); } if self.compared_snapshot.as_deref() == Some(id) { self.compared_snapshot = None; } } /// TRACES: FR-DEV-7 /// Hold a comparison against a snapshot, or let it go. Returns whether /// anything changed, so a repeat costs no render. pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool { let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id)); if self.compared_snapshot.as_deref() == id { return false; } self.compared_snapshot = id.map(str::to_string); true } /// The snapshot being held against the edit, if one is. pub fn compared_snapshot(&self) -> Option<&str> { self.compared_snapshot.as_deref() } /// TRACES: FR-DEV-7 /// Render the photograph as a snapshot has it, without becoming it. /// /// The same suspension [`Self::render_original`] uses — borrow the graph /// for one render and hand it back — because it is the same question /// about a different reference point: "the version I liked twenty /// minutes ago" instead of the file. Nothing is recorded and nothing is /// marked modified. A held comparison against a snapshot that has since /// been deleted falls back to the edit itself, which is what is on /// screen anyway. pub fn render_compared(&mut self, width: u32, height: u32) -> Result { let Some(version) = self .compared_snapshot .as_deref() .and_then(|id| self.snapshots.iter().find(|v| v.uuid == id)) .cloned() else { return self.render(width, height); }; let saved = self.graph.state(); let debt = version.apply(&mut self.graph); self.pay_film_debt(&debt); let rendered = self.render(width, height); let debt = self.graph.set_state(&saved); self.pay_film_debt(&debt); rendered } /// TRACES: FR-DEV-5 /// The snapshot list as the panel draws it, oldest first. pub fn snapshot_rows(&self) -> Vec { self.snapshots .iter() .map(|v| crate::SnapshotRow { id: v.uuid.as_str().into(), name: v.name.as_str().into(), comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()), }) .collect() } /// TRACES: FR-DEV-5 /// [`Self::pay_film_debt`] for a history step, when the step went /// anywhere. /// /// The guard is the whole difference between the two: a step that found /// nowhere to go left the graph alone, and clearing the film because /// undo hit the floor would take the picture's stock off it. pub(super) fn settle(&mut self, step: &dr_pipeline::Step) { if let dr_pipeline::Step::Took(rebake) = step { self.pay_film_debt(rebake); } } /// TRACES: FR-DEV-5 /// Step the edit back one, returning whether anything moved. /// /// The panel must be rebuilt from [`Self::rows`] afterwards, for the same /// reason a paste must: this moves values the controls are showing and /// nothing here pushes them. pub fn undo(&mut self) -> bool { let step = self.history.undo(&mut self.graph); self.settle(&step); step.moved() } /// TRACES: FR-DEV-5 /// Step the edit forward one, returning whether anything moved. pub fn redo(&mut self) -> bool { let step = self.history.redo(&mut self.graph); self.settle(&step); step.moved() } pub fn can_undo(&self) -> bool { self.history.can_undo() } pub fn can_redo(&self) -> bool { self.history.can_redo() } /// TRACES: FR-DEV-5 | FR-DEV-7 /// Every step this photograph has been through, newest first. /// /// Newest first because the list is consulted to take back something just /// done, not browsed chronologically — the order `dr_catalog::trash` /// settled on for the same question. It also keeps the interesting end /// against the heading, so a stack sixty-four deep does not put the step /// the photographer is looking for at the bottom of a long scroll. /// /// The reversal happens here rather than in the core, which returns the /// stack in stack order and stamps each row with its own index — so /// nothing on this side does arithmetic to turn a row back into a step. pub fn history_rows(&self) -> Vec { let mut rows: Vec<_> = self .history .entries(&self.graph) .into_iter() .map(|entry| crate::HistoryRow { index: entry.index as i32, label: labels::resolve(entry.label.0).into(), current: entry.current, // Everything past the mark is a future the photographer // stepped out of. Still listed, because it is still reachable // by redo and hiding it would make redo arrive somewhere the // panel never mentioned — but drawn as the branch it is. undone: entry.index > self.history.cursor(), }) .collect(); rows.reverse(); rows } /// TRACES: FR-DEV-5 | FR-DEV-7 /// Step straight to one row of [`Self::history_rows`]. /// /// Takes the row's own `index`, not its position in that list. /// TRACES: FR-DEV-5 /// A number that changes exactly when [`Self::history_rows`] would. /// /// The panel is rebuilt off this rather than every redraw: a drag ends in /// a redraw per frame and changes no row, and pushing a fresh model makes /// the toolkit tear down and recreate every one of them. pub fn history_revision(&self) -> u64 { self.history.revision() } pub fn go_to_history(&mut self, index: i32) -> bool { let Ok(index) = usize::try_from(index) else { return false; }; let step = self.history.go_to(&mut self.graph, index); self.settle(&step); step.moved() } /// TRACES: FR-DEV-5 /// What undo would take back, and what redo would put back. /// /// Named on the buttons rather than left to the bare verb. "Undo" asks the /// photographer to remember what they last did, which after a run of small /// adjustments is exactly what they have stopped tracking — and it is the /// moment they are least willing to press a button and find out. /// /// Empty when there is nowhere to go, so the caller falls back to the verb /// alone rather than printing a label for a disabled control. pub fn undo_label(&self) -> String { // Undo takes back the step the graph is *standing on*, so the row to // name is the current one — not the one it will land on. self.step_name(self.history.cursor(), self.history.can_undo()) } /// TRACES: FR-DEV-5 pub fn redo_label(&self) -> String { self.step_name(self.history.cursor() + 1, self.history.can_redo()) } pub(super) fn step_name(&self, index: usize, offered: bool) -> String { if !offered { return String::new(); } self.history .entries(&self.graph) .into_iter() .find(|e| e.index == index) .map(|e| labels::resolve(e.label.0)) .unwrap_or_default() } } #[cfg(test)] mod tests { use crate::develop::test_support::*; /// TRACES: FR-DEV-5 /// A snapshot is a state the photographer named: taking one changes /// nothing, going back to it is one step, and undo takes the whole of /// that step back. #[test] fn a_snapshot_is_restored_as_one_step_and_undone_as_one() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let rows = session.rows(); let row = rows .iter() .find(|row| { session.set_param(row.op_index, row.param_index, row.maximum); !session.is_neutral() }) .expect("some control in the panel moves the picture") .clone(); let liked = session.copy_settings(); let steps_before = session.history_rows().len(); let id = session.take_snapshot("Liked this"); assert_eq!(session.snapshots().len(), 1); assert_eq!(session.snapshots()[0].name, "Liked this"); assert_eq!( session.history_rows().len(), steps_before, "naming a state is not a change to the photograph" ); // Move on, then go back. session.set_param(row.op_index, row.param_index, row.minimum); let moved_on = session.copy_settings(); assert_ne!(moved_on, liked, "the premise: the edit has moved"); let steps_moved = session.history_rows().len(); assert!(session.restore_snapshot(&id)); assert_eq!(session.copy_settings(), liked, "back to the named state"); assert_eq!( session.history_rows().len(), steps_moved + 1, "restoring is one step" ); assert!(session.undo()); assert_eq!( session.copy_settings(), moved_on, "and undo takes the whole restore back" ); // A name nobody typed is numbered rather than blank. session.take_snapshot(" "); assert_eq!(session.snapshots()[1].name, "Snapshot 2"); session.delete_snapshot(&id); assert_eq!(session.snapshots().len(), 1); assert_eq!(session.removed_snapshots(), [id.as_str()]); } /// TRACES: FR-DEV-7 | FR-DEV-5 /// Holding a snapshot against the edit is the same bargain as holding /// the original: the picture changes, and nothing else does. #[test] fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() { let Some(ctx) = headless() else { return }; let (mut session, _) = grey_session(&ctx); let rows = session.rows(); let row = rows .iter() .find(|row| { session.set_param(row.op_index, row.param_index, row.maximum); !session.is_neutral() }) .expect("some control in the panel moves the picture") .clone(); let id = session.take_snapshot("Bright"); session.set_param(row.op_index, row.param_index, row.minimum); let edit = session.copy_settings(); let steps = session.history_rows().len(); assert!(session.compare_snapshot(Some(&id)), "the hold began"); assert!( !session.compare_snapshot(Some(&id)), "a repeat of the same hold is not a change" ); assert_eq!(session.compared_snapshot(), Some(id.as_str())); session .render_compared(64, 64) .expect("render the snapshot"); assert_eq!(session.copy_settings(), edit, "every parameter comes back"); assert_eq!(session.history_rows().len(), steps, "looking is not a step"); assert!(session.compare_snapshot(None), "and letting go is one"); assert!(session.compared_snapshot().is_none()); assert!( !session.compare_snapshot(Some("nothing-by-this-name")), "a snapshot that does not exist cannot be held" ); } }