Files
DarkRoom/ui/dr-ui/src/develop/history.rs
T
dtourolle 050c2c9d16 Split develop.rs into develop/ by area of behaviour
develop.rs had grown to 9,327 lines covering everything the develop
session does: opening a photograph, the parameter-row and curve-widget
panel model, mask viewing and editing, mask creation and the rasteriser
that turns a mask stack into GPU arrays, spot repairs, scene
segmentation, framing and zoom, white-balance sampling, rendering and
film choice, and the undo/snapshot history. docs/dev/code-health.md
CH-1 names dr-ui's lack of a view layer as the reason every feature
kept landing in a handful of files; this is the first of the two pure
splits it recommends as easy, no-behaviour-change wins independent of
that larger rework.

The boundaries follow the file's own sections (several were already
marked off with comment headers) and the seams a full read turned up
underneath them -- mask storage/rasterisation turned out to be a
distinct concern from mask viewing and editing, and rows/tabs/curves
from each other, so those split further than the headers alone
suggested. Each module stays under about 1,500 lines. Struct fields
and the handful of helper methods now called from a sibling module
became `pub(super)`, which is strictly narrower than the whole-crate
reachability a single file gave them; nothing gained visibility outside
`develop`. Tests moved with the code they test, including the few
cases where a helper one file's tests needed was itself only defined
in another's -- those became shared fixtures in `mod.rs` alongside the
`headless`/`read_back`/`grey_session` helpers that already worked that
way. `mod.rs` re-exports every item `develop::` callers outside this
module used before, so lib.rs, masks_ui.rs and the rest needed no
changes.
2026-09-20 18:21:26 +02:00

402 lines
15 KiB
Rust

//! 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<dr_pipeline::Version>) {
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<slint::Image, String> {
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<crate::SnapshotRow> {
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<crate::HistoryRow> {
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"
);
}
}