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
+51 -51
View File
File diff suppressed because one or more lines are too long
+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.
///
+147 -3
View File
@@ -398,6 +398,68 @@ fn reset_view_state(window: &AppWindow) {
spots_ui::reset(window);
}
/// TRACES: FR-UI-4
/// Where the 1:1 view is pointing, and whether it is currently on.
///
/// **Two facts rather than an `Option`, and the second one is why.** Returning
/// to fit turns the magnifier off; it does not mean the photographer has
/// stopped caring about the corner they were inspecting. An `Option` emptied
/// on the way out would send the next press of the same control back to the
/// centre of the frame, which is the one place nobody was looking.
///
/// The point is in fractions of the *framed* image, because that is the only
/// coordinate system two different photographs share.
#[derive(Debug, Clone, Copy)]
struct Inspection {
at: (f32, f32),
on: bool,
}
impl Default for Inspection {
fn default() -> Self {
// The centre, which is where a magnifier that has never been aimed
// points. `(0.0, 0.0)` would be the top-left corner — a real place,
// and never the one meant.
Self {
at: (0.5, 0.5),
on: false,
}
}
}
/// TRACES: FR-UI-4
/// Put a newly opened photograph under the magnifier the last one was under.
///
/// The other half of `reset_view_state`, and deliberately not part of it: that
/// function empties what belonged to one photograph, and the inspection point
/// belongs to the *pass*. Checking the same eye on forty portraits is why a
/// 1:1 view is worth reaching at all, and a magnification that reset with the
/// session would make it forty zooms and forty pans instead of forty
/// keystrokes.
///
/// Called after the stored edit has landed rather than before it, because the
/// point is in fractions of the *framed* image: a sidecar carrying a crop
/// changes what a fraction refers to, and inspecting before it arrived would
/// land somewhere the photograph does not have any more.
///
/// Silently does nothing where the file could not be opened for editing —
/// there is no view to place, and the next photograph that does open is still
/// inspected.
fn resume_inspection(
session: &Rc<RefCell<Option<DevelopSession>>>,
viewport: &Rc<RefCell<(u32, u32)>>,
inspection: &Rc<Cell<Inspection>>,
) {
let held = inspection.get();
if !held.on {
return;
}
let (vw, vh) = *viewport.borrow();
if let Some(s) = session.borrow_mut().as_mut() {
s.inspect_at(held.at.0, held.at.1, vw, vh);
}
}
/// Push the framing back to the geometry panel.
///
/// Separate from [`sync_rows`] because framing is no longer *in* the rows —
@@ -1646,6 +1708,25 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// histogram beside it.
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
// TRACES: FR-UI-4
// The point being inspected at 1:1, in fractions of the framed image, or
// `None` where the view is fitted.
//
// **Held here rather than on the session, on exactly the argument
// `chosen_peaking` above 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 entire reason a 1:1 view is worth reaching, and a
// magnification that reset with the session would ask for the same zoom
// and the same pan forty times. So `reset_view_state` empties the
// histogram and leaves this alone, and the two open paths below put the
// next photograph under the magnifier the last one was left under.
//
// It is a *viewing* state throughout. It never reaches the graph's crop,
// the sidecar or an export — `Framing::view` is kept out of all three, and
// `render_the_file` suspends it — so a photographer left at 4× exports the
// whole frame, as they did before this existed.
let inspection: Rc<Cell<Inspection>> = Rc::new(Cell::new(Inspection::default()));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
@@ -1964,6 +2045,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let rows = rows.clone();
let open_image = open_image.clone();
let library_for_show = library.clone();
let viewport_for_show = viewport.clone();
let inspection_for_show = inspection.clone();
Rc::new(move |window: &AppWindow| {
// The image about to be replaced is the last chance to persist its
// edit — stepping to the next frame is as much a departure as
@@ -2023,6 +2106,14 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
presets::apply_stored_edit(window, &sidecar, &session, &rows);
}
// TRACES: FR-UI-4
// And under the same magnifier as the frame
// before it, if one was up. After the sidecar,
// because the point is a fraction of the framed
// image and the sidecar is what decides how the
// frame is cropped.
resume_inspection(&session, &viewport_for_show, &inspection_for_show);
// Through `sync_rows` rather than setting rows
// directly, so the curve's drawn shape is
// refreshed by the same path that refreshes the
@@ -2075,6 +2166,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let gpu = gpu.clone();
let activity = activity.clone();
let open_image = open_image.clone();
let viewport = viewport.clone();
let inspection = inspection.clone();
*open_from_library.borrow_mut() = Some(Rc::new(move |path: String| {
let Some(w) = weak.upgrade() else { return };
@@ -2163,6 +2256,8 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// may run again for the next photograph.
let library = library.clone();
let path = path.clone();
let viewport = viewport.clone();
let inspection = inspection.clone();
let sidecar_rx = Rc::new(sidecar_rx);
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
@@ -2248,6 +2343,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
&rows,
&redraw,
);
// TRACES: FR-UI-4
// Under the same magnifier as the last
// frame, on the terms `resume_inspection`
// sets out. The sidecar may still be in
// flight here, unlike the local path — so
// a photograph whose stored crop lands a
// moment later is inspected against the
// uncropped frame until it does, which
// moves the point rather than losing it.
resume_inspection(&session, &viewport, &inspection);
sync_rows(&w, &rows, &session);
redraw(&w);
}
@@ -2756,13 +2861,46 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
{
// TRACES: FR-UI-4
// Fit and 1:1, and the memory of where 1:1 was pointing.
//
// The remembered point is written from what the session *landed* on
// rather than from the coordinates that arrived, so a point near an
// edge is stored where the view was clamped to. Storing the request
// instead would carry a point the previous photograph could not show
// to a photograph that also cannot show it, and the drift would
// accumulate over a folder.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_zoom_reset(move || {
let viewport = viewport.clone();
let inspection = inspection.clone();
window.on_inspect_toggled(move |at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
let (vw, vh) = *viewport.borrow();
if let Some(s) = session.borrow_mut().as_mut() {
s.reset_zoom();
// A negative anchor is a control with no point of its own —
// the readout button, the keyboard — asking for the last one.
// Resolved here rather than in Slint because this is where
// the memory lives.
//
// The remembered point is in fractions of the framed image and
// the session wants fractions of the *visible* area, which are
// the same thing only while the view is fitted. That is
// exactly when this branch can move the view: from a zoomed
// view the toggle goes out to fit and ignores its anchor
// entirely.
let held = inspection.get();
let (ax, ay) = if at_x < 0.0 || at_y < 0.0 {
held.at
} else {
(at_x, at_y)
};
inspection.set(match s.toggle_inspection(ax, ay, vw, vh) {
Some(at) => Inspection { at, on: true },
// The aim is kept, only switched off. See `Inspection`.
None => Inspection { on: false, ..held },
});
}
redraw(&w);
});
@@ -3446,7 +3584,13 @@ fn back_one_step(w: &AppWindow) -> bool {
BackStep::CloseSettings => w.invoke_settings_close(),
BackStep::CancelBrowse => w.invoke_launch_browse_cancel(),
BackStep::LeaveMode => w.invoke_mode_picked(ViewMode::Photo),
BackStep::ResetZoom => w.invoke_zoom_reset(),
// TRACES: FR-UI-4
// Through the toggle rather than through `zoom_reset`, because a
// zoomed view is now something the photographer is *holding* across
// photographs: putting it down has to put the magnifier down too, or
// the next frame would open under a magnification that the back
// gesture had just dismissed.
BackStep::ResetZoom => w.invoke_inspect_toggled(-1.0, -1.0),
BackStep::ToLibrary => w.invoke_back_to_library(),
BackStep::ClearScope => w.invoke_collection_select(0),
}
+61 -6
View File
@@ -230,7 +230,21 @@ export component AppWindow inherits Window {
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float);
callback pan-by(float, float);
callback zoom-reset();
/// TRACES: FR-UI-4
/// Toggle between fitting the frame and inspecting it at 1:1, anchored on
/// a point given in fractions of the visible image.
///
/// A negative coordinate means "wherever I was last looking" — the last
/// inspected point, or the centre of the frame if there has not been one.
/// A sentinel rather than a second callback, for the reason `swatch-hue`
/// is one: a control that has no point of its own still has to be able to
/// ask, and every value in `0..1` is a real place on the photograph.
///
/// Rust owns which of the two states it is, for the same reason it owns
/// the view mode: 1:1 is not a number this file can compute — it depends
/// on the file's own resolution against the viewport's — and the point is
/// remembered across photographs, which is state no screen holds.
callback inspect-toggled(float, float);
// --- launch screen (FR-NC-1, FR-NC-4) ---
//
@@ -2066,7 +2080,24 @@ in property <bool> panel-visible: true;
return accept;
}
double-clicked => { root.zoom-reset(); }
// TRACES: FR-UI-4
// Fit and 1:1, which is what FR-UI-4 asks a double
// tap for. It used to drop straight to fit, so the
// gesture only ever did half its job — and the half
// it did not do is the one that matters, since
// nothing else in the interface reached 100% at all.
//
// Anchored on the pointer, in fractions of the shown
// image, exactly as the wheel above is: the detail
// being inspected is the one under the finger, and a
// toggle that jumped to the centre would ask for a
// pan afterwards every single time.
double-clicked => {
root.inspect-toggled(
(self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px),
(self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px),
);
}
}
// --- crop overlay ------------------------------------------
@@ -2181,6 +2212,17 @@ in property <bool> panel-visible: true;
root.spot-removed(root.selected-spot);
return accept;
}
// TRACES: FR-UI-4 | FR-UI-5
// Fit and 1:1 from the keyboard, so inspecting
// forty portraits at the same eye is forty
// keystrokes rather than forty pans. Bare, with
// no modifier: the control-Z case above has
// already returned, so there is nothing left for
// this to shadow.
if (event.text == "z" || event.text == "Z") {
root.inspect-toggled(-1, -1);
return accept;
}
if (event.text == Key.RightArrow || event.text == " ") {
root.next-image();
return accept;
@@ -2270,15 +2312,28 @@ in property <bool> panel-visible: true;
clicked => { root.mode-picked(ViewMode.photo); }
}
// TRACES: FR-UI-4 | NFR-A11Y-2
// Zoom is a view state, so its readout doubles as the
// control that clears it.
// control that clears it — and, when there is nothing
// to clear, as the way in to 1:1.
//
// **The non-gesture half of the double tap.** FR-UI-4
// requires every gesture-driven action to have an
// equivalent that is not a gesture, and a magnifier
// reachable only by double-tapping the picture is a
// feature for people who already know it is there.
// Enabled unconditionally now, because "Fit" on a
// fitted photograph really was dead and this is not.
Button {
text: root.zoomed
? Math.round(root.zoom * 100) + "%"
: "Fit";
: "1:1";
active: root.zoomed;
enabled: root.zoomed;
clicked => { root.zoom-reset(); }
// No point of its own, so it asks for the last
// one: pressing this returns to whatever was
// being looked at rather than to the middle of a
// frame nobody was looking at the middle of.
clicked => { root.inspect-toggled(-1, -1); }
}
}