Let the photographer look at the pixels, and keep looking

Noise reduction and capture sharpening are judgements about individual
pixels, and at a fitted view several of the file's pixels are averaged into
each one on screen. The frame therefore looks cleaner and softer than it is,
the photographer corrects for a softness the display invented, and
over-sharpening is the documented result. Nothing in the develop view
reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap
dropped straight to fit, and the only readout was a percentage nobody was
aiming at.

So the double tap now does what FR-UI-4 always said it did — toggle fit and
1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted
photograph, becomes the way in when there is nothing to clear. Z does the
same from the keyboard, and the back gesture goes out through the same
toggle so putting the magnifier down really puts it down. 1:1 is computed
from the file's own resolution against the viewport rather than fixed at
some multiple, because that is the only version of it that answers the
question the two detail controls are asking.

The point being inspected and whether the magnifier is up are held beside
the session rather than in it, on the argument focus peaking already makes:
a session is one photograph and this is a way of looking at a folder of
them. Checking the same eye across forty portraits is the reason to reach
1:1 in the first place, and a magnification that reset with the session
would make that forty zooms and forty pans instead of forty keystrokes. It
stays a viewing state throughout — the view is kept out of `is_active`,
`output_size`, the sidecar and the export, and an export still suspends it —
so none of this reaches the file.
This commit is contained in:
2026-09-06 19:01:50 +02:00
parent 43652bd613
commit 9b674a88d8
4 changed files with 429 additions and 60 deletions
+170
View File
@@ -4372,6 +4372,104 @@ impl DevelopSession {
self.graph.framing_mut().set_view(CropRect::default());
}
/// TRACES: FR-UI-4 | FR-DSP-1
/// The zoom that puts one source pixel under one screen pixel.
///
/// **Derived from the file and the viewport rather than fixed at some
/// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px
/// viewport needs about 7× before its pixels are its own, and a
/// screen-sized JPEG needs none at all. The same arithmetic
/// [`Self::magnifies_source`] uses to decide how to *filter* the canvas,
/// asked in the other direction — which is what keeps the readout the
/// canvas shows and the zoom this lands on from disagreeing about what
/// 100% means.
///
/// Never below 1.0: fitting is as far out as the view goes, so a
/// photograph already smaller than the viewport is at 1:1 the moment it
/// is fitted.
pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
if rw == 0 {
return 1.0;
}
(fw as f32 / rw as f32).max(1.0)
}
/// TRACES: FR-UI-4
/// Where the view is centred, in fractions of the framed image.
///
/// The form the inspection point is *remembered* in, and it has to be
/// this one: the point is carried to the next photograph, and fractions
/// of the frame are the only coordinates two different files share.
pub fn inspection_point(&self) -> (f32, f32) {
let v = self.graph.framing().view();
(v.x + v.width / 2.0, v.y + v.height / 2.0)
}
/// TRACES: FR-UI-4
/// Put the view at 1:1, centred on a point in fractions of the framed
/// image.
///
/// Separate from [`Self::toggle_inspection`] because the two callers are
/// not the same person: the toggle is a photographer pressing something,
/// and this is the next photograph arriving under the magnifier the last
/// one was left under.
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
let extent =
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
}
/// TRACES: FR-UI-4 | FR-DEV-3
/// Toggle between fitting the frame and inspecting it at 1:1.
///
/// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture
/// sharpening are judgements about individual pixels, and at a fitted
/// view several source pixels are averaged into each screen pixel — so
/// the frame looks cleaner and softer than it is, and the photographer
/// corrects for a softness the display invented. Over-sharpening is the
/// documented result. There is exactly one magnification at which those
/// two controls are telling the truth, and this is the gesture that
/// reaches it without anyone reading a percentage.
///
/// `at_x`/`at_y` are fractions of the *visible* area — the same
/// coordinates [`Self::zoom_about`] takes, because they come from the
/// same pointer over the same box.
///
/// Returns the point now under inspection in fractions of the framed
/// image, or `None` where the view has gone back to fit. That is the
/// answer *after* clamping, so a point near an edge is remembered where
/// the view actually landed rather than where the finger was — otherwise
/// the next photograph would be inspected somewhere the previous one
/// never showed.
///
/// Leaves the history alone, and must: this changes no pixel of the file.
/// See [`Self::framing_edits_image`] for the same distinction drawn from
/// the other side.
pub fn toggle_inspection(
&mut self,
at_x: f32,
at_y: f32,
viewport_w: u32,
viewport_h: u32,
) -> Option<(f32, f32)> {
// Out from *any* zoom, not only from 1:1. The gesture means "show me
// the whole photograph again", and a scroll wheel that stopped at
// 173% must not leave the toggle inert.
if self.is_zoomed() {
self.reset_zoom();
return None;
}
let view = self.graph.framing().view();
let x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.inspect_at(x, y, viewport_w, viewport_h);
Some(self.inspection_point())
}
/// Place a square view of `extent`, keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
@@ -5361,6 +5459,78 @@ mod tests {
(session, before)
}
/// TRACES: FR-UI-4
/// The inspection zoom is 1:1 for *this* file in *this* viewport.
///
/// The number is the whole point. A magnifier that lands on some fixed
/// multiple tells the photographer nothing about whether they are looking
/// at the file's own pixels, and that is the only question noise reduction
/// and capture sharpening can honestly be judged by.
#[test]
fn inspecting_lands_on_one_source_pixel_per_screen_pixel() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
// Sixty-four source pixels fitted into thirty-two is one screen pixel
// per two of the file's, so 1:1 is 2×.
let one_to_one = session.one_to_one_zoom(32, 32);
assert!(
(one_to_one - 2.0).abs() < 1e-3,
"a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}"
);
assert!(
session.toggle_inspection(0.5, 0.5, 32, 32).is_some(),
"the first toggle goes in"
);
assert!(
(session.zoom() - one_to_one).abs() < 1e-3,
"the view should have landed on 1:1, not {}",
session.zoom()
);
}
/// TRACES: FR-UI-4
/// The second press goes back to fit — from any zoom, not only from 1:1.
///
/// A scroll wheel that stopped at 173% must not leave the toggle inert:
/// the gesture means "show me the whole photograph again".
#[test]
fn the_inspection_toggle_returns_to_fit_from_any_zoom() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.zoom_about(3.0, 0.5, 0.5);
assert!(session.is_zoomed(), "the premise");
assert_eq!(
session.toggle_inspection(0.5, 0.5, 32, 32),
None,
"toggling out reports no inspection point"
);
assert!(!session.is_zoomed());
}
/// TRACES: FR-UI-4 | FR-DEV-5
/// Inspecting is a way of looking, and leaves no trace on the photograph.
///
/// The failure this guards is quiet and expensive: a zoom that recorded a
/// step would put a viewport rectangle on the undo stack and into the
/// sidecar, and the photograph would then open on another device cropped
/// to wherever somebody once looked.
#[test]
fn inspecting_writes_nothing_the_file_would_remember() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
assert!(!session.can_undo(), "the premise: nothing has been done");
session.toggle_inspection(0.25, 0.75, 32, 32);
assert!(!session.can_undo(), "a zoom is not a step to take back");
assert!(session.is_neutral(), "and it is not an edit either");
assert!(!session.framing_edits_image());
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// Reopening an edited photograph renders its subject mask, with no model.
///