Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing implemented it. What the develop view had was history navigation, which *changes* the edit rather than previewing against it — so the only way to look was to undo, look, and redo, and that puts two real steps on the stack at exactly the moment a photographer suspects they have overcooked a frame and is least sure of what they are doing. Holding the "Before" button, or backslash, renders the graph with every adjustment stripped and hands it straight back afterwards: the same suspend-render-restore shape the crop overlay already uses to show an uncropped frame and an export uses to suspend the zoom. Nothing is recorded, no rows are re-synced, and the photograph is still modified when the key comes up — the panel goes on describing the edit the photographer has, because only the canvas is answering a question. The framing deliberately stays on. A held comparison is a question about tone and colour, and re-cropping the canvas under someone's thumb would move the detail they are comparing; worse, the zoom is a rectangle of the *framed* image, so dropping the crop at 4× would quietly show a different part of the photograph rather than the same part unedited. What the crop took away is already compared in Compose, which shows the whole frame. Not a split screen: that halves the working image on the tablet this column was sized for, and the comparison photographers describe making is a flick back and forth rather than two pictures side by side. Press-and-hold is one gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants, and it has no mode to be stranded in — the button reports both edges, so a press the system cancels puts the original down too.
This commit is contained in:
+48
-48
File diff suppressed because one or more lines are too long
@@ -3605,6 +3605,72 @@ impl DevelopSession {
|
|||||||
Ok((image, rw, rh))
|
Ok((image, rw, rh))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// Render the photograph as the file has it, with every adjustment off.
|
||||||
|
///
|
||||||
|
/// **What a held comparison shows, and it is not a history state.**
|
||||||
|
/// FR-DEV-7 asks for the current edit against the unedited original, and
|
||||||
|
/// the only thing the interface had was [`Self::go_to_history`] — which
|
||||||
|
/// *changes* the edit rather than previewing against it. A photographer
|
||||||
|
/// who suspects they have overcooked a frame therefore had to undo, look,
|
||||||
|
/// and redo, which puts two real steps on the stack at exactly the moment
|
||||||
|
/// they are least sure of what they are doing.
|
||||||
|
///
|
||||||
|
/// This puts none there. It borrows the graph for the length of one
|
||||||
|
/// render and hands it back: the same shape [`Self::render_uncropped`]
|
||||||
|
/// uses for the crop and [`Self::render_the_file`] uses for the zoom, and
|
||||||
|
/// for the same reason — the graph is the one description of the
|
||||||
|
/// photograph, so a second rendering of it is a suspension rather than a
|
||||||
|
/// copy. Nothing is recorded, nothing is marked modified, and a caller
|
||||||
|
/// asking whether the image differs from its defaults gets the same
|
||||||
|
/// answer before and after.
|
||||||
|
///
|
||||||
|
/// **The framing stays on**, and that is a decision rather than an
|
||||||
|
/// oversight. A held before/after is a question about tone and colour —
|
||||||
|
/// "have I pushed this too far" — and re-cropping the canvas under the
|
||||||
|
/// photographer's thumb would move the very detail they are comparing.
|
||||||
|
/// It would also make the view meaningless: the zoom is a rectangle of the
|
||||||
|
/// *framed* image, so dropping the crop at 4× would show a different part
|
||||||
|
/// of the photograph rather than the same part unedited. What the crop
|
||||||
|
/// took away is compared in Compose, which already shows the whole frame.
|
||||||
|
///
|
||||||
|
/// Restored whatever happens, for the reason `render_uncropped` restores
|
||||||
|
/// its crop: leaving the graph stripped after a failed render would
|
||||||
|
/// discard the entire edit, silently.
|
||||||
|
pub fn render_original(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||||||
|
let saved = self.graph.state();
|
||||||
|
self.strip_adjustments();
|
||||||
|
let rendered = self.render(width, height);
|
||||||
|
let debt = self.graph.set_state(&saved);
|
||||||
|
self.pay_film_debt(&debt);
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// Take everything off the graph except the shape of the frame.
|
||||||
|
///
|
||||||
|
/// Four removals rather than [`EditGraph::reset`], which would take the
|
||||||
|
/// framing with it. The empty preset applied at
|
||||||
|
/// [`Scope::adjustments`] — the scope that is defined as "all of it but
|
||||||
|
/// the crop" — clears every parameter the photographer can move, and the
|
||||||
|
/// three things that are not parameters go by hand: local adjustments,
|
||||||
|
/// repairs, and the film stock. A local adjustment is as much an edit as
|
||||||
|
/// the slider that drives it, so an "original" still wearing its masks
|
||||||
|
/// would be answering a different question.
|
||||||
|
///
|
||||||
|
/// **Only ever inside a suspension.** This leaves the graph describing a
|
||||||
|
/// photograph nobody asked for, so every caller restores from a
|
||||||
|
/// [`EditGraph::state`] taken first.
|
||||||
|
fn strip_adjustments(&mut self) {
|
||||||
|
Preset::default().apply(&mut self.graph, Scope::adjustments());
|
||||||
|
*self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new();
|
||||||
|
*self.graph.spots_mut() = dr_pipeline::SpotSet::new();
|
||||||
|
// Through the session rather than the graph: the baked tables live on
|
||||||
|
// the adjust pass as well, and clearing one without the other is the
|
||||||
|
// silent disagreement `set_film` exists to prevent.
|
||||||
|
self.set_film(None);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||||||
/// Give back the GPU memory this session is holding only to be fast.
|
/// Give back the GPU memory this session is holding only to be fast.
|
||||||
///
|
///
|
||||||
@@ -5531,6 +5597,47 @@ mod tests {
|
|||||||
assert!(!session.framing_edits_image());
|
assert!(!session.framing_edits_image());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||||||
|
/// A held comparison hands the edit straight back.
|
||||||
|
///
|
||||||
|
/// This is the whole difference between comparing and the
|
||||||
|
/// undo-look-redo that had to stand in for it. Two steps on the stack, at
|
||||||
|
/// the moment a photographer is least sure of what they are doing, was
|
||||||
|
/// the price of looking — and this asserts the price is now nothing:
|
||||||
|
/// the same parameters, the same history, still modified.
|
||||||
|
#[test]
|
||||||
|
fn showing_the_original_leaves_the_edit_exactly_as_it_was() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let (mut session, _) = grey_session(&ctx);
|
||||||
|
|
||||||
|
// Addressed by index, so this names no operation (FR-DEV-3a).
|
||||||
|
let row = session.rows()[0].clone();
|
||||||
|
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||||
|
|
||||||
|
let edit = session.copy_settings();
|
||||||
|
let steps = session.history_rows().len();
|
||||||
|
assert!(!session.is_neutral(), "the premise: there is an edit");
|
||||||
|
|
||||||
|
session
|
||||||
|
.render_original(64, 64)
|
||||||
|
.expect("render the original");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.copy_settings(),
|
||||||
|
edit,
|
||||||
|
"every parameter comes back where it was"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.history_rows().len(),
|
||||||
|
steps,
|
||||||
|
"looking is not a step to take back"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!session.is_neutral(),
|
||||||
|
"and the photograph is still modified"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
/// Reopening an edited photograph renders its subject mask, with no model.
|
/// Reopening an edited photograph renders its subject mask, with no model.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -370,6 +370,14 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// previous image's history.
|
// previous image's history.
|
||||||
window.set_can_undo(false);
|
window.set_can_undo(false);
|
||||||
window.set_can_redo(false);
|
window.set_can_redo(false);
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// And the comparison goes down with them. Unlike the inspection point
|
||||||
|
// below it, this is not a way of looking at a folder: it is a question
|
||||||
|
// about one photograph's edit, asked while holding something. A key
|
||||||
|
// release that arrived after the next frame had opened would put the view
|
||||||
|
// back anyway, but a press that opens the next photograph — the roll is a
|
||||||
|
// tap away — must not carry a held original onto it.
|
||||||
|
window.set_showing_original(false);
|
||||||
// TRACES: FR-DSP-7
|
// TRACES: FR-DSP-7
|
||||||
// Emptied rather than left standing: the previous photograph's histogram
|
// Emptied rather than left standing: the previous photograph's histogram
|
||||||
// beside the next one's filename is a confident, precise lie, and the gap
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
@@ -1821,11 +1829,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
h = (h / 2).max(1);
|
h = (h / 2).max(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// Three renders of one graph, chosen here because this is the one
|
||||||
|
// path every frame takes — so a comparison held while a slider is
|
||||||
|
// still settling shows the original at full resolution too, rather
|
||||||
|
// than reverting the moment anything else asks for a redraw.
|
||||||
|
//
|
||||||
// Crop mode shows the whole frame, or the area being cropped away
|
// Crop mode shows the whole frame, or the area being cropped away
|
||||||
// would not be on screen for the handles to drag across. The
|
// would not be on screen for the handles to drag across. The
|
||||||
// overlay draws the rect on top of it.
|
// overlay draws the rect on top of it.
|
||||||
let rendered = if window.get_view_mode() == ViewMode::Crop {
|
let rendered = if window.get_view_mode() == ViewMode::Crop {
|
||||||
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
s.render_uncropped(w, h).map(|(image, _, _)| image)
|
||||||
|
} else if window.get_showing_original() {
|
||||||
|
s.render_original(w, h)
|
||||||
} else {
|
} else {
|
||||||
s.render(w, h)
|
s.render(w, h)
|
||||||
};
|
};
|
||||||
@@ -2789,6 +2805,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// Holding the comparison, and letting it go.
|
||||||
|
//
|
||||||
|
// **No rows are synced and no history is touched**, and both absences
|
||||||
|
// are the feature. The panel is describing the edit the photographer
|
||||||
|
// still has; only the canvas changes, and it changes back. This is the
|
||||||
|
// whole difference between a comparison and the undo-look-redo that
|
||||||
|
// had to stand in for one — that put two real steps on the stack at
|
||||||
|
// the moment somebody was least sure of what they were doing.
|
||||||
|
//
|
||||||
|
// A full frame rather than a draft: comparing a half-resolution
|
||||||
|
// original against a sharp edit would show a difference the edit does
|
||||||
|
// not have, which is the one thing this must not do.
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let render_now = render_now.clone();
|
||||||
|
window.on_compare_original(move |on| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
// A held key repeats. Answering a repeat with a re-render would
|
||||||
|
// spend a full-resolution pass per keystroke to arrive back at the
|
||||||
|
// frame already on screen.
|
||||||
|
if w.get_showing_original() == on {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.set_showing_original(on);
|
||||||
|
render_now(&w, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-DEV-5 | FR-DEV-7
|
// TRACES: FR-DEV-5 | FR-DEV-7
|
||||||
// Clicking a row. Arriving six steps away costs what arriving from one
|
// Clicking a row. Arriving six steps away costs what arriving from one
|
||||||
|
|||||||
@@ -219,6 +219,24 @@ export component AppWindow inherits Window {
|
|||||||
callback undo();
|
callback undo();
|
||||||
callback redo();
|
callback redo();
|
||||||
|
|
||||||
|
// --- before and after (FR-DEV-7) ---
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-7
|
||||||
|
/// Whether the canvas is currently showing the unedited original.
|
||||||
|
///
|
||||||
|
/// Rust owns it, and the ownership is the point: this is *not* a history
|
||||||
|
/// position and must never become one. Held down, the canvas renders the
|
||||||
|
/// photograph with the adjustments taken off and the framing left on; let
|
||||||
|
/// go, the edit comes back. Nothing is recorded, so a photographer can
|
||||||
|
/// check whether they have overcooked a frame without paying two steps of
|
||||||
|
/// undo for the look.
|
||||||
|
in property <bool> showing-original: false;
|
||||||
|
/// The comparison is being held, or has been let go.
|
||||||
|
///
|
||||||
|
/// Both edges through one callback rather than a press and a release, so
|
||||||
|
/// the two cannot get out of step and strand the view on the original.
|
||||||
|
callback compare-original(bool);
|
||||||
|
|
||||||
/// Every step, newest first. Rust owns the order and stamps each row with
|
/// 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
|
/// its own position in the stack, so nothing here does arithmetic to turn
|
||||||
/// a row back into a step.
|
/// a row back into a step.
|
||||||
@@ -2182,11 +2200,30 @@ in property <bool> panel-visible: true;
|
|||||||
// the canvas is noticed.
|
// the canvas is noticed.
|
||||||
key-released(event) => {
|
key-released(event) => {
|
||||||
root.shift-held = event.modifiers.shift;
|
root.shift-held = event.modifiers.shift;
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// Letting go of backslash puts the edit back. The
|
||||||
|
// release has to be handled here and not inferred
|
||||||
|
// from the next press, or a photographer who holds
|
||||||
|
// it and then reaches for a slider would be
|
||||||
|
// adjusting an image they cannot see.
|
||||||
|
if (event.text == "\\") {
|
||||||
|
root.compare-original(false);
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
return reject;
|
return reject;
|
||||||
}
|
}
|
||||||
|
|
||||||
key-pressed(event) => {
|
key-pressed(event) => {
|
||||||
root.shift-held = event.modifiers.shift;
|
root.shift-held = event.modifiers.shift;
|
||||||
|
// TRACES: FR-DEV-7
|
||||||
|
// Hold backslash to see the unedited original.
|
||||||
|
// Rust ignores a repeat that says what it already
|
||||||
|
// knows, so the key repeating under a long look
|
||||||
|
// costs nothing.
|
||||||
|
if (event.text == "\\") {
|
||||||
|
root.compare-original(true);
|
||||||
|
return accept;
|
||||||
|
}
|
||||||
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
// Ctrl+Z and Ctrl+Shift+Z (FR-DEV-5). Both cases
|
||||||
// of the letter, because the logical key that
|
// of the letter, because the logical key that
|
||||||
// reaches us carries the shift: holding it for
|
// reaches us carries the shift: holding it for
|
||||||
@@ -2335,6 +2372,33 @@ in property <bool> panel-visible: true;
|
|||||||
// frame nobody was looking at the middle of.
|
// frame nobody was looking at the middle of.
|
||||||
clicked => { root.inspect-toggled(-1, -1); }
|
clicked => { root.inspect-toggled(-1, -1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-7 | FR-DEV-3b | NFR-A11Y-2
|
||||||
|
// The unedited original, for as long as it is held.
|
||||||
|
//
|
||||||
|
// **A hold rather than a split screen.** A split view
|
||||||
|
// halves the working image, which on the tablet this
|
||||||
|
// column was sized for leaves neither half worth
|
||||||
|
// judging; and the comparison a photographer actually
|
||||||
|
// describes making is a flick back and forth, not two
|
||||||
|
// pictures side by side. A hold is also the cheaper
|
||||||
|
// thing to build: one extra render of a graph that is
|
||||||
|
// already there, against a second canvas that would
|
||||||
|
// have to be laid out, sized and kept in step.
|
||||||
|
//
|
||||||
|
// Press-and-hold is the same gesture with a finger and
|
||||||
|
// with a mouse, which is what FR-DEV-3b's mapping
|
||||||
|
// wants — no long-press timer, no mode, and no way to
|
||||||
|
// be left in it. The keyboard's backslash does the
|
||||||
|
// same thing on both edges; see the focus scope below.
|
||||||
|
Button {
|
||||||
|
text: "Before";
|
||||||
|
enabled: root.adjust-enabled;
|
||||||
|
active: root.showing-original;
|
||||||
|
accessible-checkable: true;
|
||||||
|
accessible-checked: root.showing-original;
|
||||||
|
held(down) => { root.compare-original(down); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The photo roll, along the foot of the canvas.
|
// The photo roll, along the foot of the canvas.
|
||||||
|
|||||||
@@ -68,6 +68,25 @@ export component Button inherits Rectangle {
|
|||||||
in property <bool> active: false;
|
in property <bool> active: false;
|
||||||
|
|
||||||
callback clicked();
|
callback clicked();
|
||||||
|
/// TRACES: FR-DEV-7 | FR-UI-3
|
||||||
|
/// The button is down, or is no longer down.
|
||||||
|
///
|
||||||
|
/// Almost every button acts on release and ignores this. A *held* control
|
||||||
|
/// is the exception — "show me the original while I am holding this" — and
|
||||||
|
/// `clicked` describes only the end of that gesture, by which time the
|
||||||
|
/// thing being looked at has gone.
|
||||||
|
///
|
||||||
|
/// Here rather than in a second component because the two are one button
|
||||||
|
/// in every other respect: the same box, the same states, the same 44pt
|
||||||
|
/// target grown around a compact rectangle. A `HoldButton` beside this one
|
||||||
|
/// would be sixty duplicated lines and a second place for the press
|
||||||
|
/// treatment to drift, which is the failure this file's preamble is about.
|
||||||
|
///
|
||||||
|
/// Reported from the pointer rather than from `touch.pressed`, so that a
|
||||||
|
/// press cancelled by the system — a call arriving, a gesture claimed by
|
||||||
|
/// the shell — puts the button up. A held comparison that stuck on would
|
||||||
|
/// leave the photographer editing the original.
|
||||||
|
callback held(bool);
|
||||||
|
|
||||||
accessible-role: button;
|
accessible-role: button;
|
||||||
accessible-label: root.text;
|
accessible-label: root.text;
|
||||||
@@ -115,6 +134,15 @@ export component Button inherits Rectangle {
|
|||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||||
clicked => { root.clicked(); }
|
clicked => { root.clicked(); }
|
||||||
|
pointer-event(ev) => {
|
||||||
|
if (ev.kind == PointerEventKind.down) {
|
||||||
|
root.held(true);
|
||||||
|
}
|
||||||
|
if (ev.kind == PointerEventKind.up
|
||||||
|
|| ev.kind == PointerEventKind.cancel) {
|
||||||
|
root.held(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
|
|||||||
Reference in New Issue
Block a user