List the steps, and let a photographer step straight to one
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Build and test / Desktop (Linux) (push) Successful in 19m30s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m5s
Undo answers "take back the last thing", which is the question asked about a mistake just noticed. It is the wrong instrument for one noticed six adjustments later: eight presses, each changing the picture, with no way to see how far back the mistake is without passing through it. A step is a whole state, so arriving from six away costs what arriving from one does — which is what makes a row worth making clickable rather than decorative. `Edit::Discrete` had to go for the list to be worth drawing. Seventeen call sites recorded the same anonymous step, which is fine for deciding whether two changes are one gesture and useless for a panel: seventeen rows reading "Discrete" is not a history. Every variant now carries enough to name itself, and the compiler enumerated the sites that had to start saying so. A step that moved a parameter is still named out of the descriptor, so an operation added as a YAML declaration appears in the history correctly named with nothing written for it (FR-DEV-3c). Choosing a film stock was not undoable at all. The pick went straight to `choose_film`, which nothing on the history's path ever sees. `pick_film` records it, and is separate because the same call is also how a *restored* edit gets its tables back — recording that would push a step for the undo the photographer had just asked for. The list is rebuilt off a revision rather than off every redraw. A drag ends in a redraw per frame while folding into one step, so the unconditional version would tear down and recreate every row sixty times a second to arrive back at the list already on screen. The counter is process-wide: a per-instance one starts every photograph at the same number, so a frontend holding "the revision I last drew" would keep the previous image's steps on screen — invisible while every image opens with one identical row, and a wrong-photograph bug the moment persisted history means it does not. The step names that no descriptor can supply are constants with a roll, and a test walks the roll rather than a second copy of it. `resolve` splits so that "is this catalogued?" can be asked: `derive` turns `history.mask_toggled` into "Mask Toggled", which names a field rather than an act and, being perfectly readable, is a mistake nobody would look at twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+156
-28
@@ -1336,7 +1336,8 @@ impl DevelopSession {
|
||||
layer.set_param(id, param, default);
|
||||
}
|
||||
}
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_OP));
|
||||
return;
|
||||
}
|
||||
for p in &cap.params {
|
||||
@@ -1344,7 +1345,8 @@ impl DevelopSession {
|
||||
}
|
||||
// One step, though it moved every parameter the operation has: the
|
||||
// user pressed one button.
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_OP));
|
||||
}
|
||||
|
||||
/// Reset a curve, which is to reset its operation.
|
||||
@@ -1400,12 +1402,14 @@ impl DevelopSession {
|
||||
.map(|p| p.default)
|
||||
.unwrap_or(0.0);
|
||||
self.graph.set_param(op, param, default);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_PARAM));
|
||||
}
|
||||
|
||||
pub fn reset_all(&mut self) {
|
||||
self.graph.reset();
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_ALL));
|
||||
}
|
||||
|
||||
/// Rasterise the current mask stack, if there is one.
|
||||
@@ -1981,7 +1985,8 @@ impl DevelopSession {
|
||||
/// Called on the pointer's release rather than on each move, which is what
|
||||
/// makes a drag one decision in the undo stack however many frames it took.
|
||||
pub fn commit_gradient_drag(&mut self) {
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_MOVED));
|
||||
}
|
||||
|
||||
// --- repairs (FR-DEV-8) ------------------------------------------------
|
||||
@@ -2019,7 +2024,8 @@ impl DevelopSession {
|
||||
.spots_mut()
|
||||
.place(dr_pipeline::Spot::new(centre, offset, radius))?;
|
||||
self.selected_spot = Some(id.clone());
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_PLACED));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -2145,7 +2151,8 @@ impl DevelopSession {
|
||||
|
||||
/// A repair's drag finished: one history step for the whole gesture.
|
||||
pub fn commit_spot_drag(&mut self) {
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_MOVED));
|
||||
}
|
||||
|
||||
/// Which repair the column is describing.
|
||||
@@ -2179,17 +2186,18 @@ impl DevelopSession {
|
||||
if self.selected_spot.as_deref() == Some(id) {
|
||||
self.selected_spot = None;
|
||||
}
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED));
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Change one of the selected repair's settings.
|
||||
///
|
||||
/// Coalesced under [`Edit::Discrete`] like the drags are, rather than under
|
||||
/// a parameter key: a repair is not an operation and has no `OpId` to
|
||||
/// coalesce by, so a slider drag over it records a step per movement unless
|
||||
/// the caller debounces. `SliderRow` fires once per completed gesture,
|
||||
/// Recorded as a named [`Edit::Action`] rather than under a parameter key:
|
||||
/// a repair is not an operation and has no `OpId` to name or coalesce by,
|
||||
/// so a slider drag over it records a step per movement unless the caller
|
||||
/// debounces. `SliderRow` fires once per completed gesture,
|
||||
/// which is what makes that acceptable here and is why this is the one
|
||||
/// panel in the application built from that row rather than from a live
|
||||
/// track.
|
||||
@@ -2204,7 +2212,8 @@ impl DevelopSession {
|
||||
return false;
|
||||
};
|
||||
change(spot);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -2290,7 +2299,8 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -2319,28 +2329,32 @@ impl DevelopSession {
|
||||
return None;
|
||||
}
|
||||
self.active_masks = vec![id.clone()];
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
pub fn remove_mask(&mut self, id: &str) {
|
||||
if self.graph.masks_mut().remove(id).is_some() {
|
||||
self.active_masks.retain(|a| a != id);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_REMOVED));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) {
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||
layer.enabled = enabled;
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_mask_invert(&mut self, id: &str, invert: bool) {
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||
layer.invert = invert;
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_INVERTED));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2349,7 +2363,7 @@ impl DevelopSession {
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||
layer.feather = feather.clamp(0.0, 1.0);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Op(OpId("mask-feather")));
|
||||
.record(&self.graph, Edit::Control(labels::step::MASK_FEATHER));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2360,7 +2374,8 @@ impl DevelopSession {
|
||||
};
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||
layer.falloff = falloff;
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2376,7 +2391,8 @@ impl DevelopSession {
|
||||
if morphology != Morphology::None && layer.morph_radius <= 0.0 {
|
||||
layer.morph_radius = 0.006;
|
||||
}
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2384,7 +2400,7 @@ impl DevelopSession {
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||
layer.morph_radius = radius.clamp(0.0, 1.0);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Op(OpId("mask-morph")));
|
||||
.record(&self.graph, Edit::Control(labels::step::MASK_MORPH));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2439,7 +2455,7 @@ impl DevelopSession {
|
||||
// one decision however many values it passes through. `Discrete`
|
||||
// would put every intermediate position on the undo stack.
|
||||
self.history
|
||||
.record(&self.graph, Edit::Op(OpId("mask-opacity")));
|
||||
.record(&self.graph, Edit::Control(labels::step::MASK_OPACITY));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2704,6 +2720,25 @@ impl DevelopSession {
|
||||
/// is the sync case — a sidecar written on a device with a stock this one
|
||||
/// lacks — and rendering it as *some other* film would be worse than
|
||||
/// rendering it plainly.
|
||||
/// TRACES: FR-DEV-3f | FR-DEV-5
|
||||
/// Choose a stock **as the photographer just did**, and record the step.
|
||||
///
|
||||
/// Separate from [`Self::choose_film`] because that call has two very
|
||||
/// different callers. Picking Portra from the list is an edit and belongs
|
||||
/// in the history; the same call made while *restoring* an edit — opening
|
||||
/// a photograph, or stepping to a history row that names a stock — is the
|
||||
/// second half of putting a state back, and recording it would push a step
|
||||
/// for the undo the photographer had just asked for.
|
||||
///
|
||||
/// Choosing a stock was not undoable at all before this existed: the pick
|
||||
/// went straight to `choose_film`, which nothing on the history's path
|
||||
/// ever sees.
|
||||
pub fn pick_film(&mut self, stock: Option<&str>, print: bool) {
|
||||
self.choose_film(stock, print);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::FILM));
|
||||
}
|
||||
|
||||
pub fn choose_film(&mut self, stock: Option<&str>, print: bool) {
|
||||
let Some(stock) = stock else {
|
||||
self.set_film(None);
|
||||
@@ -2929,7 +2964,8 @@ impl DevelopSession {
|
||||
self.graph.set_crop(rotate_crop(crop, turns));
|
||||
}
|
||||
self.graph.rotate_quarters(turns);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::ROTATE));
|
||||
}
|
||||
|
||||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||||
@@ -2954,7 +2990,8 @@ impl DevelopSession {
|
||||
dr_pipeline::framing::FLIP_H,
|
||||
f32::from(u8::from(!h)),
|
||||
);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::FLIP_H));
|
||||
}
|
||||
|
||||
pub fn toggle_flip_v(&mut self) {
|
||||
@@ -2964,7 +3001,8 @@ impl DevelopSession {
|
||||
dr_pipeline::framing::FLIP_V,
|
||||
f32::from(u8::from(!v)),
|
||||
);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::FLIP_V));
|
||||
}
|
||||
|
||||
/// Set the straightening angle, in degrees.
|
||||
@@ -3000,7 +3038,8 @@ impl DevelopSession {
|
||||
let view = self.graph.framing().view();
|
||||
self.graph.framing_mut().reset();
|
||||
self.graph.framing_mut().set_view(view);
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_FRAMING));
|
||||
}
|
||||
|
||||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||||
@@ -3137,7 +3176,8 @@ impl DevelopSession {
|
||||
// A paste is undoable, and is the action most in need of it: it
|
||||
// replaces everything in scope at once, so getting it wrong costs more
|
||||
// than any single control can.
|
||||
self.history.record(&self.graph, Edit::Discrete);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::PASTE));
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-8
|
||||
@@ -3233,6 +3273,94 @@ impl DevelopSession {
|
||||
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())
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-express a crop rect after the frame it is measured against turns.
|
||||
|
||||
+243
-56
@@ -9,47 +9,153 @@
|
||||
//! a derived label, before anyone writes a translation for it. That is the
|
||||
//! behaviour FR-DEV-3c promises: adding an operation needs no UI change.
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// The history steps this interface records that no descriptor can name.
|
||||
///
|
||||
/// Constants rather than string literals at the call sites, and [`ALL`] rather
|
||||
/// than a list written out again in a test. A key spelled one way where the
|
||||
/// step is recorded and another way where it is catalogued resolves through
|
||||
/// `derive` to something *plausible* — "Mask Toggled" — so the mistake does
|
||||
/// not look like one. Naming each key once removes the opportunity.
|
||||
///
|
||||
/// A step that moved a parameter or an operation is deliberately not here: it
|
||||
/// is named out of the descriptor, through the same `op.` and `param.` entries
|
||||
/// the develop panel resolves. That is what lets an operation added as a YAML
|
||||
/// declaration appear in the history correctly named with nothing written for
|
||||
/// it here (FR-DEV-3c).
|
||||
pub mod step {
|
||||
use dr_pipeline::LocalizedKey;
|
||||
|
||||
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
||||
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
||||
|
||||
pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all");
|
||||
pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op");
|
||||
pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param");
|
||||
pub const RESET_FRAMING: LocalizedKey = LocalizedKey("history.reset_framing");
|
||||
|
||||
pub const ROTATE: LocalizedKey = LocalizedKey("history.rotate");
|
||||
pub const FLIP_H: LocalizedKey = LocalizedKey("history.flip_h");
|
||||
pub const FLIP_V: LocalizedKey = LocalizedKey("history.flip_v");
|
||||
|
||||
pub const MASK_ADDED: LocalizedKey = LocalizedKey("history.mask_added");
|
||||
pub const MASK_REMOVED: LocalizedKey = LocalizedKey("history.mask_removed");
|
||||
pub const MASK_TOGGLED: LocalizedKey = LocalizedKey("history.mask_toggled");
|
||||
pub const MASK_INVERTED: LocalizedKey = LocalizedKey("history.mask_inverted");
|
||||
pub const MASK_MOVED: LocalizedKey = LocalizedKey("history.mask_moved");
|
||||
pub const MASK_FEATHER: LocalizedKey = LocalizedKey("history.mask_feather");
|
||||
pub const MASK_MORPH: LocalizedKey = LocalizedKey("history.mask_morph");
|
||||
pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity");
|
||||
pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff");
|
||||
pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology");
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// A repair's own settings changed — its radius, its source, its opacity.
|
||||
pub const SPOT: LocalizedKey = LocalizedKey("history.spot");
|
||||
pub const SPOT_PLACED: LocalizedKey = LocalizedKey("history.spot_placed");
|
||||
pub const SPOT_MOVED: LocalizedKey = LocalizedKey("history.spot_moved");
|
||||
pub const SPOT_REMOVED: LocalizedKey = LocalizedKey("history.spot_removed");
|
||||
|
||||
/// Every step above, plus the two the core names for itself.
|
||||
///
|
||||
/// Exists so the catalogue can be checked against the keys that are
|
||||
/// actually recorded rather than against a second copy of them. A new step
|
||||
/// left out of this list is a step with no test — which is the failure
|
||||
/// this whole arrangement is guarding, so it is worth saying plainly:
|
||||
/// **add the constant to this slice.**
|
||||
///
|
||||
/// Test-only, because checking is the whole of what it is for. Resolving a
|
||||
/// key at runtime goes through [`super::resolve`] one key at a time and
|
||||
/// never needs the roll.
|
||||
#[cfg(test)]
|
||||
pub const ALL: &[LocalizedKey] = &[
|
||||
dr_pipeline::history::OPENED,
|
||||
dr_pipeline::history::UNNAMED,
|
||||
PASTE,
|
||||
FILM,
|
||||
RESET_ALL,
|
||||
RESET_OP,
|
||||
RESET_PARAM,
|
||||
RESET_FRAMING,
|
||||
ROTATE,
|
||||
FLIP_H,
|
||||
FLIP_V,
|
||||
MASK_ADDED,
|
||||
MASK_REMOVED,
|
||||
MASK_TOGGLED,
|
||||
MASK_INVERTED,
|
||||
MASK_MOVED,
|
||||
MASK_FEATHER,
|
||||
MASK_MORPH,
|
||||
MASK_OPACITY,
|
||||
MASK_FALLOFF,
|
||||
MASK_MORPHOLOGY,
|
||||
SPOT,
|
||||
SPOT_PLACED,
|
||||
SPOT_MOVED,
|
||||
SPOT_REMOVED,
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolve a key, deriving a fallback where none is catalogued.
|
||||
pub fn resolve(key: &str) -> String {
|
||||
match key {
|
||||
catalogued(key).map_or_else(|| derive(key), str::to_string)
|
||||
}
|
||||
|
||||
/// The label this build has actually chosen for `key`, if it has chosen one.
|
||||
///
|
||||
/// Split out from [`resolve`] so that "is this catalogued?" can be asked, and
|
||||
/// it is asked by the test that guards the history rows. Those are read as a
|
||||
/// column of short phrases naming decisions, and `derive` is not up to that
|
||||
/// job even when it produces real words: it turns `history.mask_toggled` into
|
||||
/// "Mask Toggled", which is a description of a field rather than of something
|
||||
/// the photographer did — and, being perfectly readable, is a mistake nobody
|
||||
/// would look at twice.
|
||||
///
|
||||
/// Deriving stays right for `op.` and `param.`, where an operation added as a
|
||||
/// YAML declaration must show up usable before anyone writes its translation
|
||||
/// (FR-DEV-3c). The difference is that those keys name a thing and these name
|
||||
/// an act.
|
||||
fn catalogued(key: &str) -> Option<&'static str> {
|
||||
Some(match key {
|
||||
// Operations
|
||||
// The attribute names. Short on purpose: these are read as a strip
|
||||
// of tabs, where a long word crowds out the next one.
|
||||
"attr.tone" => "Light".into(),
|
||||
"attr.colour" => "Colour".into(),
|
||||
"attr.detail" => "Detail".into(),
|
||||
"attr.optics" => "Optics".into(),
|
||||
"attr.geometry" => "Geometry".into(),
|
||||
"attr.effect" => "Effects".into(),
|
||||
"attr.tone" => "Light",
|
||||
"attr.colour" => "Colour",
|
||||
"attr.detail" => "Detail",
|
||||
"attr.optics" => "Optics",
|
||||
"attr.geometry" => "Geometry",
|
||||
"attr.effect" => "Effects",
|
||||
|
||||
"op.white_balance" => "White Balance".into(),
|
||||
"op.exposure" => "Exposure".into(),
|
||||
"op.highlights_shadows" => "Highlights & Shadows".into(),
|
||||
"op.blacks_whites" => "Blacks & Whites".into(),
|
||||
"op.brilliance" => "Brilliance".into(),
|
||||
"op.vibrance" => "Vibrance".into(),
|
||||
"op.saturation" => "Saturation".into(),
|
||||
"op.colour_mixer" => "Colour Mixer".into(),
|
||||
"op.white_balance" => "White Balance",
|
||||
"op.exposure" => "Exposure",
|
||||
"op.highlights_shadows" => "Highlights & Shadows",
|
||||
"op.blacks_whites" => "Blacks & Whites",
|
||||
"op.brilliance" => "Brilliance",
|
||||
"op.vibrance" => "Vibrance",
|
||||
"op.saturation" => "Saturation",
|
||||
"op.colour_mixer" => "Colour Mixer",
|
||||
// "Sharpening" rather than what `derive` would make of the id. The id
|
||||
// says *capture* sharpening to separate it from the output sharpening
|
||||
// an export applies (FR-EXP-4), which is a distinction about where in
|
||||
// the pipeline it sits; in the develop panel there is only one, and
|
||||
// "Capture Sharpen" would name a distinction the photographer cannot
|
||||
// see from there.
|
||||
"op.capture_sharpen" => "Sharpening".into(),
|
||||
"op.framing" => "Crop & Rotate".into(),
|
||||
"op.capture_sharpen" => "Sharpening",
|
||||
"op.framing" => "Crop & Rotate",
|
||||
|
||||
// Parameters
|
||||
"param.temperature" => "Temperature".into(),
|
||||
"param.tint" => "Tint".into(),
|
||||
"param.exposure" => "Exposure".into(),
|
||||
"param.highlights" => "Highlights".into(),
|
||||
"param.shadows" => "Shadows".into(),
|
||||
"param.blacks" => "Blacks".into(),
|
||||
"param.whites" => "Whites".into(),
|
||||
"param.brilliance" => "Brilliance".into(),
|
||||
"param.vibrance" => "Vibrance".into(),
|
||||
"param.saturation" => "Saturation".into(),
|
||||
"param.temperature" => "Temperature",
|
||||
"param.tint" => "Tint",
|
||||
"param.exposure" => "Exposure",
|
||||
"param.highlights" => "Highlights",
|
||||
"param.shadows" => "Shadows",
|
||||
"param.blacks" => "Blacks",
|
||||
"param.whites" => "Whites",
|
||||
"param.brilliance" => "Brilliance",
|
||||
"param.vibrance" => "Vibrance",
|
||||
"param.saturation" => "Saturation",
|
||||
|
||||
// What a faceted parameter adjusts — the mixer's three channels.
|
||||
//
|
||||
@@ -57,9 +163,9 @@ pub fn resolve(key: &str) -> String {
|
||||
// "Lum" from the keys. These name a run of twelve rows apiece, and an
|
||||
// abbreviation at the head of a section is a word the reader has to
|
||||
// expand every time they scan past it.
|
||||
"param.channel.hue" => "Hue".into(),
|
||||
"param.channel.sat" => "Saturation".into(),
|
||||
"param.channel.lum" => "Luminance".into(),
|
||||
"param.channel.hue" => "Hue",
|
||||
"param.channel.sat" => "Saturation",
|
||||
"param.channel.lum" => "Luminance",
|
||||
|
||||
// The tone curve's four curves, which its points are *subject* to.
|
||||
//
|
||||
@@ -69,10 +175,10 @@ pub fn resolve(key: &str) -> String {
|
||||
// colours would derive correctly and are written out beside it anyway,
|
||||
// since a list where one entry is translated and three are guessed is
|
||||
// the shape a half-finished translation takes.
|
||||
"channel.rgb" => "RGB".into(),
|
||||
"channel.red" => "Red".into(),
|
||||
"channel.green" => "Green".into(),
|
||||
"channel.blue" => "Blue".into(),
|
||||
"channel.rgb" => "RGB",
|
||||
"channel.red" => "Red",
|
||||
"channel.green" => "Green",
|
||||
"channel.blue" => "Blue",
|
||||
|
||||
// The hue bands, which a faceted row is *subject* to.
|
||||
//
|
||||
@@ -82,32 +188,76 @@ pub fn resolve(key: &str) -> String {
|
||||
// nothing. These are also the only place a swatch's meaning is written
|
||||
// down in words, which is what a photographer who cannot separate the
|
||||
// squares by eye has to go on.
|
||||
"band.red" => "Red".into(),
|
||||
"band.orange" => "Orange".into(),
|
||||
"band.yellow" => "Yellow".into(),
|
||||
"band.chartreuse" => "Yellow-Green".into(),
|
||||
"band.green" => "Green".into(),
|
||||
"band.spring" => "Blue-Green".into(),
|
||||
"band.cyan" => "Cyan".into(),
|
||||
"band.azure" => "Azure".into(),
|
||||
"band.blue" => "Blue".into(),
|
||||
"band.violet" => "Violet".into(),
|
||||
"band.magenta" => "Magenta".into(),
|
||||
"band.rose" => "Rose".into(),
|
||||
"band.red" => "Red",
|
||||
"band.orange" => "Orange",
|
||||
"band.yellow" => "Yellow",
|
||||
"band.chartreuse" => "Yellow-Green",
|
||||
"band.green" => "Green",
|
||||
"band.spring" => "Blue-Green",
|
||||
"band.cyan" => "Cyan",
|
||||
"band.azure" => "Azure",
|
||||
"band.blue" => "Blue",
|
||||
"band.violet" => "Violet",
|
||||
"band.magenta" => "Magenta",
|
||||
"band.rose" => "Rose",
|
||||
|
||||
// Framing. "Straighten" rather than "Angle" because that is the task
|
||||
// the control performs; the number it reports is still degrees.
|
||||
"param.angle" => "Straighten".into(),
|
||||
"param.rotation" => "Rotate".into(),
|
||||
"param.flip_h" => "Flip Horizontal".into(),
|
||||
"param.flip_v" => "Flip Vertical".into(),
|
||||
"param.crop_x" => "Crop Left".into(),
|
||||
"param.crop_y" => "Crop Top".into(),
|
||||
"param.crop_w" => "Crop Width".into(),
|
||||
"param.crop_h" => "Crop Height".into(),
|
||||
"param.angle" => "Straighten",
|
||||
"param.rotation" => "Rotate",
|
||||
"param.flip_h" => "Flip Horizontal",
|
||||
"param.flip_v" => "Flip Vertical",
|
||||
"param.crop_x" => "Crop Left",
|
||||
"param.crop_y" => "Crop Top",
|
||||
"param.crop_w" => "Crop Width",
|
||||
"param.crop_h" => "Crop Height",
|
||||
|
||||
other => derive(other),
|
||||
}
|
||||
// TRACES: FR-DEV-5
|
||||
// The steps that are not a parameter moving.
|
||||
//
|
||||
// Catalogued rather than derived because these are read as a *column*
|
||||
// of short phrases, where `derive` would give "Mask Added" and "Reset
|
||||
// Op" — the second of which names a function rather than an action,
|
||||
// and the first of which reads as a label for a thing rather than for
|
||||
// something the photographer did. A history is a list of decisions, so
|
||||
// the rows are written as decisions.
|
||||
//
|
||||
// A step naming an operation or a parameter is *not* here: it resolves
|
||||
// through the same `op.` and `param.` entries above that the panel
|
||||
// uses, which is what lets an operation added as a YAML declaration
|
||||
// appear in the history correctly named with no entry written for it
|
||||
// (FR-DEV-3c).
|
||||
"history.opened" => "Opened",
|
||||
"history.edit" => "Edit",
|
||||
"history.paste" => "Paste Settings",
|
||||
"history.film" => "Film Stock",
|
||||
|
||||
"history.reset_all" => "Reset Everything",
|
||||
"history.reset_op" => "Reset Group",
|
||||
"history.reset_param" => "Reset Control",
|
||||
"history.reset_framing" => "Reset Crop & Rotate",
|
||||
|
||||
"history.rotate" => "Rotate",
|
||||
"history.flip_h" => "Flip Horizontal",
|
||||
"history.flip_v" => "Flip Vertical",
|
||||
|
||||
"history.mask_added" => "Add Mask",
|
||||
"history.mask_removed" => "Delete Mask",
|
||||
"history.mask_toggled" => "Mask On/Off",
|
||||
"history.mask_inverted" => "Invert Mask",
|
||||
"history.mask_moved" => "Move Mask",
|
||||
"history.mask_feather" => "Mask Feather",
|
||||
"history.mask_morph" => "Mask Edge",
|
||||
"history.mask_opacity" => "Mask Opacity",
|
||||
"history.mask_falloff" => "Mask Falloff",
|
||||
"history.mask_morphology" => "Mask Grow/Shrink",
|
||||
"history.spot" => "Adjust Repair",
|
||||
"history.spot_placed" => "Add Repair",
|
||||
"history.spot_moved" => "Move Repair",
|
||||
"history.spot_removed" => "Delete Repair",
|
||||
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn `op.some_new_thing` into `Some New Thing`.
|
||||
@@ -137,6 +287,43 @@ fn derive(key: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_step_the_interface_records_has_a_name() {
|
||||
// A history is only as useful as its rows are distinguishable, and
|
||||
// these are the ones no descriptor can name — the resets, the flips,
|
||||
// the mask actions. A key missing from the catalogue derives something
|
||||
// readable rather than nothing, so the failure this guards is subtler:
|
||||
// two different steps deriving the *same* word, or a word that names
|
||||
// the function that ran rather than the decision the user made.
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for key in step::ALL.iter().map(|k| k.0) {
|
||||
let label = catalogued(key).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{key} is not catalogued; it would fall through to `derive` \
|
||||
and read as {:?}, which names a field rather than an act",
|
||||
derive(key)
|
||||
)
|
||||
});
|
||||
assert!(!label.is_empty(), "{key} resolved to nothing");
|
||||
assert!(
|
||||
!seen.contains(&label.to_string()),
|
||||
"{key} shares the label {label:?} with an earlier step"
|
||||
);
|
||||
seen.push(label.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_naming_an_operation_borrows_the_panels_label() {
|
||||
// What keeps the history honest as the pipeline grows: a step that
|
||||
// moved a parameter is named by the same entry the slider beside it
|
||||
// is, so the two can never disagree, and an operation nobody has
|
||||
// catalogued yet still reads correctly in both places.
|
||||
assert_eq!(resolve("op.white_balance"), "White Balance");
|
||||
assert_eq!(resolve("op.tone_curve"), "Tone Curve");
|
||||
assert_eq!(resolve("param.exposure"), "Exposure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogued_keys_resolve_to_their_label() {
|
||||
assert_eq!(resolve("op.white_balance"), "White Balance");
|
||||
|
||||
+45
-3
@@ -43,7 +43,7 @@ mod sidecar_cache;
|
||||
mod spots_ui;
|
||||
mod trash;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -1303,9 +1303,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
//
|
||||
// Called on every slider change, so it must do no more than run the
|
||||
// adjust pass — the demosaic is not repeated.
|
||||
// TRACES: FR-DEV-5
|
||||
// The history revision the panel was last built from. See the use below:
|
||||
// the list is rebuilt when it would read differently, not when the picture
|
||||
// is redrawn, and those are very different rates.
|
||||
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
||||
|
||||
let render_now: Render = {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
let drawn_history = drawn_history.clone();
|
||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
@@ -1318,6 +1325,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
window.set_can_undo(s.can_undo());
|
||||
window.set_can_redo(s.can_redo());
|
||||
|
||||
// TRACES: FR-DEV-5 | FR-DEV-7
|
||||
// The list, on the same path and for the same reason — but only
|
||||
// when it would read differently. A drag ends in a redraw per
|
||||
// frame while folding into one step, so an unconditional rebuild
|
||||
// here would tear down and recreate every row of the panel sixty
|
||||
// times a second to arrive back at the list already on screen.
|
||||
let revision = s.history_revision();
|
||||
if drawn_history.get() != Some(revision) {
|
||||
drawn_history.set(Some(revision));
|
||||
window
|
||||
.set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows())));
|
||||
window.set_undo_label(s.undo_label().into());
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-3
|
||||
// Which part of the region overlay the view is showing. Here
|
||||
// rather than in the panel's own sync because a pan or a zoom
|
||||
@@ -2089,7 +2110,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
.and_then(dr_film::find)
|
||||
.and_then(dr_film::default_print)
|
||||
.is_some();
|
||||
s.choose_film(*stock, print);
|
||||
s.pick_film(*stock, print);
|
||||
}
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
@@ -2103,7 +2124,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) {
|
||||
s.choose_film(Some(&stock), print);
|
||||
s.pick_film(Some(&stock), print);
|
||||
}
|
||||
}
|
||||
sync_film(&w, &session);
|
||||
@@ -2149,6 +2170,27 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-5 | FR-DEV-7
|
||||
// Clicking a row. Arriving six steps away costs what arriving from one
|
||||
// does, because a step is a whole state — see `History::go_to`.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
window.on_history_picked(move |index| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let stepped = session
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
.is_some_and(|s| s.go_to_history(index));
|
||||
if stepped {
|
||||
sync_rows(&w, &rows, &session);
|
||||
redraw(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- zoom, pan and crop ---------------------------------------------
|
||||
//
|
||||
// Zoom and pan are viewing state and touch no parameter, so unlike the
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { SpotHandle, SpotPanel, SpotRole } from "spots.slint";
|
||||
import { HistoryPanel, HistoryRow } from "history.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
@@ -361,6 +362,15 @@ export component AppWindow inherits Window {
|
||||
in property <bool> can-redo: false;
|
||||
callback undo();
|
||||
callback redo();
|
||||
|
||||
/// Every step, newest first. Rust owns the order and stamps each row with
|
||||
/// its own position in the stack, so nothing here does arithmetic to turn
|
||||
/// a row back into a step.
|
||||
in property <[HistoryRow]> history-rows;
|
||||
/// What undo would take back, resolved. Empty when there is nowhere to go.
|
||||
in property <string> undo-label: "";
|
||||
/// A row's own `index`.
|
||||
callback history-picked(int);
|
||||
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
|
||||
callback zoom-at(float, float, float);
|
||||
callback pan-by(float, float);
|
||||
@@ -2604,6 +2614,35 @@ in property <bool> panel-visible: true;
|
||||
film-picked(i) => { root.film-picked(i); }
|
||||
film-print-toggled(on) => { root.film-print-toggled(on); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Last in the column, and the length is why. The
|
||||
// stack runs sixty-four deep, so anywhere else it
|
||||
// would push the controls it is a record of below
|
||||
// the fold. Its own rows run newest-first, which
|
||||
// puts the steps worth reaching immediately under
|
||||
// the heading rather than at the far end of them.
|
||||
//
|
||||
// Present in local mode as well. The mask work
|
||||
// *is* history — adding a layer, moving a
|
||||
// gradient and feathering an edge are all steps —
|
||||
// and a list that emptied when the photographer
|
||||
// entered the mode that produces the most steps
|
||||
// would be a list they stopped trusting.
|
||||
HistoryPanel {
|
||||
rows: root.history-rows;
|
||||
enabled: root.adjust-enabled;
|
||||
can-undo: root.can-undo;
|
||||
can-redo: root.can-redo;
|
||||
undo-label: root.undo-label;
|
||||
undo => { root.undo(); }
|
||||
redo => { root.redo(); }
|
||||
picked(i) => { root.history-picked(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// TRACES: FR-DEV-5 | FR-DEV-7
|
||||
// The steps this photograph has been through, and the way back to any of them.
|
||||
//
|
||||
// **Nothing here names an operation, and nothing here names a step.** The rows
|
||||
// arrive already resolved — Rust turns each step's localisation key into a
|
||||
// display string against the UI's catalogue — so an operation added to the
|
||||
// pipeline as a YAML declaration appears in this list under a sensible name
|
||||
// with no change to this file, on the same terms it appears in the panel
|
||||
// (FR-DEV-3c).
|
||||
//
|
||||
// **Why a list and not just the two buttons.** Undo answers "take back the
|
||||
// last thing", which is the question a photographer asks about the mistake
|
||||
// they have just noticed. It is the wrong instrument for the one they notice
|
||||
// six adjustments later: eight presses, each changing the picture, with no way
|
||||
// to see how far back the mistake was without passing through it. A step is a
|
||||
// whole state in `dr-pipeline`, so arriving at one from six away costs what
|
||||
// arriving from one does — which is what makes a row worth making clickable
|
||||
// rather than decorative.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint";
|
||||
|
||||
// One step, flattened for Slint's model system.
|
||||
export struct HistoryRow {
|
||||
// Routing back to the core: this step's position in the stack. **Not** its
|
||||
// position in this list, which runs the other way — see `history-rows` in
|
||||
// `develop.rs` for why the two are deliberately different numbers.
|
||||
index: int,
|
||||
|
||||
// Resolved in Rust against the UI's catalogue; the core deals in keys.
|
||||
label: string,
|
||||
|
||||
// The state the photograph is in right now. Exactly one row carries it.
|
||||
current: bool,
|
||||
|
||||
// A step the photographer has stepped back *out* of, still reachable by
|
||||
// redo. Listed rather than hidden — redo would otherwise arrive somewhere
|
||||
// this panel never mentioned — but drawn as the branch it is.
|
||||
undone: bool,
|
||||
}
|
||||
|
||||
component StepRow inherits Rectangle {
|
||||
in property <HistoryRow> data;
|
||||
in property <bool> enabled: true;
|
||||
callback picked();
|
||||
|
||||
height: Theme.row-height;
|
||||
background: root.data.current
|
||||
? Theme.selected
|
||||
: (touch.has-hover ? Theme.hover : transparent);
|
||||
|
||||
touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
enabled: root.enabled;
|
||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
// Small, because the panel around this already pads. The row's
|
||||
// background is the highlight for the current step, so it wants to be
|
||||
// a band the width of the column rather than a chip inset from it.
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// The mark for where the photograph stands. A filled bar against the
|
||||
// leading edge rather than a tick beside the name: the eye finds one
|
||||
// edge down a column of forty rows, and a glyph in the text column
|
||||
// would have to be read.
|
||||
Rectangle {
|
||||
width: 2px;
|
||||
height: parent.height;
|
||||
background: root.data.current ? Theme.active : transparent;
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.data.label;
|
||||
emphasised: root.data.current || touch.has-hover;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
// Dimmed rather than removed: this step is a future the
|
||||
// photographer stepped out of, and it is still where redo goes.
|
||||
opacity: root.data.undone ? 0.45 : 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component HistoryPanel inherits Rectangle {
|
||||
in property <[HistoryRow]> rows;
|
||||
in property <bool> enabled: true;
|
||||
in property <bool> can-undo: false;
|
||||
in property <bool> can-redo: false;
|
||||
/// What undo would take back, already resolved. Empty when there is
|
||||
/// nowhere to go.
|
||||
in property <string> undo-label: "";
|
||||
|
||||
callback undo();
|
||||
callback redo();
|
||||
/// A row's own `index`, not its position in `rows`.
|
||||
callback picked(int);
|
||||
|
||||
background: Theme.surface;
|
||||
|
||||
// **Flat, deliberately** — `MaskPanel` carries the long version of this
|
||||
// and it applies here unchanged: a nested layout under-reports its height,
|
||||
// so what follows it gets drawn on top of what came before, which is
|
||||
// invisible in the source and obvious the moment anyone opens the panel.
|
||||
// Every element is a direct child of the one layout that measures them,
|
||||
// and the ones that come and go carry their own condition rather than
|
||||
// being grouped inside a wrapper.
|
||||
//
|
||||
// No explicit height either, for the same reason `AdjustPanel` and
|
||||
// `MaskPanel` declare none: the row count changes as the photographer
|
||||
// works, and a height pinned to a layout's preferred size is one more
|
||||
// thing that has to keep up with a repeater.
|
||||
VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
// A heading, not a collapsible. `GeometryPanel` carries the argument
|
||||
// and it holds here: the list is last in the column, so what a lid
|
||||
// would save is scrolling past nothing.
|
||||
HorizontalLayout {
|
||||
PanelHeading { text: "HISTORY"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
if root.enabled && root.rows.length > 1: Value {
|
||||
text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps");
|
||||
}
|
||||
}
|
||||
|
||||
if !root.enabled: Caption { text: "No image"; }
|
||||
|
||||
// The pair, here as well as in the status strip. The strip's copy is
|
||||
// the one that survives the column being put away; this one sits
|
||||
// against the list that says what it will do, which is the pairing
|
||||
// that makes either of them legible.
|
||||
if root.enabled: HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Button {
|
||||
text: "Undo";
|
||||
enabled: root.can-undo;
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.undo(); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Redo";
|
||||
enabled: root.can-redo;
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.redo(); }
|
||||
}
|
||||
}
|
||||
|
||||
// What the left-hand button would take back, spelled out.
|
||||
//
|
||||
// On a caption rather than on the button, because `Button` sizes to
|
||||
// its label and does not elide: "Undo Highlights & Shadows" is most of
|
||||
// a 280px column on its own, and two of those would lever the column
|
||||
// open — `MaskPanel` has the same note about an unwrapped sentence.
|
||||
//
|
||||
// Undo only. Redo's destination is the row directly above the mark in
|
||||
// the list below, where it can be seen; the step undo takes back is
|
||||
// the one the photographer is standing on and stopped tracking after a
|
||||
// run of small adjustments, which is the moment they are least willing
|
||||
// to press a button to find out.
|
||||
if root.enabled && root.can-undo: Caption {
|
||||
text: "Undo: " + root.undo-label;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
if root.enabled && root.rows.length > 0: Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Newest first. The list is consulted to take back something just
|
||||
// done rather than browsed from the beginning — the order
|
||||
// `dr_catalog::trash` settled on for the same question — and it keeps
|
||||
// the end being worked at against the heading rather than sixty-four
|
||||
// rows below it.
|
||||
for row in root.rows: StepRow {
|
||||
data: row;
|
||||
enabled: root.enabled;
|
||||
picked => { root.picked(row.index); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user