Merge: focus peaking, so a frame can be judged without zooming to 100%

FR-CULL-3's peaking half. The raw histogram and raw clipping indicators
remain unbuilt -- what exists is a display histogram tagged FR-DSP-7,
counting AdjustPass's 8-bit output, which reports a highlight as gone
precisely where FR-CULL-3 needs it to report the highlight recoverable.

Verified before merge: fmt clean, clippy --workspace --all-targets
-D warnings green, 11 focus GPU tests, 79 baseline dr-gpu tests, 511
dr-ui tests. The cfg(target_os = "android") arm is unverified -- the
host-target clippy never compiled it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	ui/dr-ui/src/lib.rs
#	ui/dr-ui/ui/app.slint
This commit is contained in:
2026-08-29 21:43:35 +02:00
9 changed files with 1781 additions and 1 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,6 +22,7 @@ mod adjust;
mod demosaic;
mod detail;
mod error;
mod focus;
mod histogram;
mod mask;
mod readback;
@@ -34,6 +35,7 @@ pub use adjust::AdjustPass;
pub use demosaic::{DemosaicedImage, Demosaicer};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError;
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation.
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
+141
View File
@@ -0,0 +1,141 @@
// TRACES: FR-CULL-3 | NFR-P14
// Marking what is sharp, in a layer laid over the frame rather than into it.
//
// # Why the top octave, and not a gradient
//
// The obvious detector is a gradient magnitude — Sobel, or a central
// difference — and it is the wrong one, for a reason that decides whether the
// overlay is useful at all. A gradient answers "is there an edge here", and a
// defocused edge is still an edge: blur a 100-code step with a two-pixel
// Gaussian and the peak gradient is still around 20 codes per pixel, larger
// than a genuinely sharp edge across a low-contrast texture. Peaking built on
// gradients lights up the out-of-focus background of every portrait ever
// taken, which is the frame it exists to reject.
//
// What separates sharp from soft is *scale*, not amplitude. Defocus is a
// low-pass: it removes the top octave and leaves everything below it intact.
// So the detector is a high-pass — this pixel against the mean of its eight
// neighbours, a discrete Laplacian — which by construction responds only to
// the frequencies defocus destroys.
//
// The arithmetic, on a one-dimensional step of height D:
//
// | profile | abs(centre - mean of 8) |
// |--------------------------|-------------------------|
// | hard step, 1 px | 0.375 D |
// | Gaussian blur, sigma 1 | ~0.10 D |
// | Gaussian blur, sigma 2 | ~0.03 D |
// | linear ramp, any slope | 0 |
//
// The ramp row is the property being bought: the smooth luminance falloff
// across an out-of-focus highlight scores zero however bright it is.
//
// # Why luma, and why the histogram's luma
//
// One channel rather than three, because a colour edge carrying no luminance
// difference is both rare and, at the acuity an overlay is read at, invisible.
// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709
// weighting on the same encoded values — so the two instruments in this
// application agree about what "luma" means. Two definitions of brightness in
// one panel is the kind of disagreement nobody finds until it has already
// misled someone.
//
// # Why the frame is read where it is encoded, and not in linear light
//
// This runs on the output of the display transform, on encoded values, and
// that is deliberate: a fixed difference in sRGB code values is roughly
// equally visible wherever it sits in the range, which is what a transfer
// curve is for. Measured in linear light the same detector would need a
// threshold that varied with exposure, and a shadow texture the photographer
// can plainly see would score a hundredth of the identical texture in the
// highlights. The encoding has already done the normalisation, so the
// threshold is one number.
//
// # Why this writes a layer and not the picture
//
// The frame the compositor is handed is also what the histogram counts and
// what an export renders (`app.slint`, on the region overlay: a diagnostic
// "must not reach the histogram, an export, or the texture the develop pass
// hands the compositor"). So the marks go in their own texture — transparent
// everywhere except where something is in focus — and the compositor blends
// them. Nothing about the photograph changes, and the peaking overlay cannot
// leak into a measurement or a file.
//
// Alpha is written as exactly 0 or exactly 1, never between. The importing
// compositor's convention for whether colour arrives premultiplied is not
// something this shader can see, and at those two values the two conventions
// agree — which is a cheaper guarantee than being right about which one it is.
struct Params {
width: u32,
height: u32,
// Luma difference at which a pixel is called in focus. See
// `PeakSensitivity::threshold` for where the three values come from.
threshold: f32,
// std140 rounds the scalar block up to 16 bytes before the vec4; named so
// the Rust struct's padding is visibly the same shape.
pad_0: u32,
// The mark's colour, fully saturated. Its alpha is ignored — see above.
marker: vec4<f32>,
}
@group(0) @binding(0) var frame: texture_2d<f32>;
@group(0) @binding(1) var<uniform> params: Params;
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl`
/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity.
fn luma(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(54.0, 183.0, 19.0) / 256.0);
}
/// A neighbour, with the frame edge held rather than wrapped.
///
/// Clamping duplicates the edge pixel into the missing half of the
/// neighbourhood, which pulls the mean towards the centre and so biases the
/// response *down* on the outermost row and column. That is the right
/// direction to be wrong in: the failure is a missing mark at the frame edge,
/// where nobody is judging focus, rather than a false mark produced by
/// folding the opposite side of the picture into the kernel.
fn neighbour(x: i32, y: i32) -> f32 {
let cx = clamp(x, 0i, i32(params.width) - 1i);
let cy = clamp(y, 0i, i32(params.height) - 1i);
return luma(textureLoad(frame, vec2<i32>(cx, cy), 0).rgb);
}
// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine
// invocations and no workgroup-memory tile is built to avoid that: at viewport
// resolution the reads are perfectly coherent and the texture cache serves
// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch
// measured in tenths of a millisecond, so there is nothing here worth the
// complexity of a tiled load.
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) {
return;
}
let x = i32(gid.x);
let y = i32(gid.y);
// The eight neighbours, centre excluded. Excluded rather than folded in
// because it makes the response readable: `abs(c - mean8)` is the height
// of this pixel above its surroundings in the same units as the step it
// sits on, so the threshold can be quoted as a luma difference rather than
// as eight-ninths of one.
var sum = 0.0;
for (var dy = -1; dy <= 1; dy = dy + 1) {
for (var dx = -1; dx <= 1; dx = dx + 1) {
if (dx != 0 || dy != 0) {
sum = sum + neighbour(x + dx, y + dy);
}
}
}
let centre = luma(textureLoad(frame, vec2<i32>(x, y), 0).rgb);
let response = abs(centre - sum / 8.0);
if (response >= params.threshold) {
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
} else {
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
}
}
+21
View File
@@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in
not the bill. **It has not been measured on the device**, which is the first thing to do if the
develop view feels heavy on the tablet; do not assume this is the cause without a number.
### And a second transfer, while focus peaking is on
Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own
`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is
no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on
the device while the picture underneath it did not would simply never be seen. So
`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path
carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one.
This is recorded under TD-1 rather than as its own entry because it is not an independent choice.
It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the
canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it:
whichever of the three fixes above lands removes the readback for the frame and the overlay
together, because both are the same missing capability.
Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same
gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement.
And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay`
returns on its first line when peaking is off, so with it off there is no dispatch and no transfer,
and the Android frame path is exactly what it was before this feature existed.
### Paying it off
Any one of these removes it:
+124 -1
View File
@@ -14,7 +14,8 @@ use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
HistogramPass, MaskPass,
};
use dr_pipeline::mask::{MaskLayer, MaskSource};
@@ -722,6 +723,25 @@ pub struct DevelopSession {
/// old driver, a device without the storage-buffer atomics it needs — the
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
/// TRACES: FR-CULL-3
/// The focus-peaking overlay, on the same terms as the histogram above:
/// optional, because a device that cannot compile the pass is still a
/// device that can develop the photograph. What is lost is an instrument,
/// not the picture.
peak: Option<FocusPeakPass>,
/// TRACES: FR-CULL-3
/// What the photographer asked the overlay to look like, or `None` for
/// off.
///
/// **Interface state, not part of the edit** — the same category as
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
/// not in the sidecar, and it is not on the undo stack: pressing undo
/// after switching peaking on should take back the last *edit*, not the
/// last thing looked at.
///
/// An `Option` rather than a bool plus a settings field, so that "off" and
/// "on, in some configuration" cannot disagree with each other.
peaking: Option<FocusPeaking>,
/// TRACES: FR-DEV-3
/// The region map local masks select from, once it has been computed.
@@ -889,6 +909,10 @@ impl DevelopSession {
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
peak: FocusPeakPass::new(ctx)
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
.ok(),
peaking: None,
segmentation: None,
masks: None,
subjects: None,
@@ -2903,6 +2927,105 @@ impl DevelopSession {
.ok()
}
/// TRACES: FR-CULL-3
/// Whether this device could build the focus-peaking overlay.
///
/// Asked by the interface so that it can say the overlay is unavailable
/// rather than offer a switch that does nothing. The same courtesy the
/// histogram is not paid, and should be: a control that silently does
/// nothing is worse than one that is visibly absent.
pub fn peaking_available(&self) -> bool {
self.peak.is_some()
}
/// TRACES: FR-CULL-3
/// What the overlay is set to, or `None` when it is off.
pub fn peaking(&self) -> Option<FocusPeaking> {
self.peaking
}
/// TRACES: FR-CULL-3
/// Switch the overlay on with these settings, or off.
///
/// Asking for peaking on a device that could not build the pass leaves it
/// off, so that [`Self::peaking`] never claims something is being drawn
/// that is not. Switching off drops the overlay textures rather than
/// merely stopping drawing them: a resident overlay from the last frame is
/// one interface bug away from being laid over the next photograph.
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
self.peaking = settings.filter(|_| self.peak.is_some());
if self.peaking.is_none() {
if let Some(pass) = self.peak.as_mut() {
pass.clear();
}
}
}
/// TRACES: FR-CULL-3 | NFR-P14
/// Mark the in-focus regions of the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced**, exactly as
/// [`Self::histogram`] does and for the same reason: the overlay has to
/// describe what the photographer is looking at, and rendering a second
/// time to measure it would cost a pass and admit the possibility of the
/// two disagreeing about the picture.
///
/// That the frame is the displayed one is what makes the marks land where
/// the eye is. It is at viewport resolution, cropped and zoomed as the
/// view is, and — the point of FR-CULL-3 — descended from sensor data
/// through the demosaic rather than from the camera's embedded JPEG, whose
/// in-body sharpening this would otherwise be measuring at least as much
/// as the lens.
///
/// **Call this only after a settled render.** See
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
/// cannot be measured for sharpness.
///
/// `None` where nothing has been rendered, where peaking is off, or where
/// the device could not build the pass.
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
let settings = self.peaking?;
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
// and holding a shared borrow of `self.adjust` across the mutable
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
// something the clone says in one word.
let frame = self.adjust.output()?.clone();
let pass = self.peak.as_mut()?;
let overlay = pass
.render(&frame, settings)
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
.ok()?
.clone();
#[cfg(not(target_os = "android"))]
{
// A layer over the canvas rather than a tint in it, so nothing
// here reaches the histogram or an export — see `FocusPeakPass`
// for the whole of that argument.
slint::Image::try_from(overlay)
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
.ok()
}
// Android draws with Skia over OpenGL and cannot sample a
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
// through memory (technical-debt.md TD-1). The measurement still
// happens on the GPU; only this last hop does not.
#[cfg(target_os = "android")]
{
let _ = overlay;
let (rgba, w, h) = pass
.read_overlay()
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
.ok()?;
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let wanted = (w as usize) * (h as usize) * 4;
let src = &rgba[..wanted.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
Some(slint::Image::from_rgba8(buf))
}
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
+127
View File
@@ -39,6 +39,7 @@ mod library_ui;
mod live_style;
mod masks_ui;
mod net_runtime;
mod peaking;
mod preset_store;
mod presets;
mod remote;
@@ -317,6 +318,12 @@ fn reset_view_state(window: &AppWindow) {
// beside the next one's filename is a confident, precise lie, and the gap
// before the new frame settles is exactly long enough to read it.
window.set_histogram(histogram::empty());
// TRACES: FR-CULL-3
// The marks go down with it, and for the same reason. What is *not* reset
// is whether peaking is switched on: that is a way of looking at a folder
// rather than a property of one photograph, so it survives to the next
// frame — see `chosen_peaking` for the whole of that argument.
window.set_focus_overlay_ready(false);
// TRACES: FR-DEV-3
// The region map belongs to one photograph. Carrying the stack, the
// overlay or the crosshair to the next one would offer a selection of
@@ -1465,11 +1472,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// is redrawn, and those are very different rates.
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
// TRACES: FR-CULL-3
// How the photographer wants focus peaking drawn, or `None` for off.
//
// **Held here rather than on the session, which is the opposite of where
// every edit lives.** A session is one photograph; peaking is a way of
// *looking* at a folder of them. Someone culling three thousand frames
// switches it on once, and a flag that reset with the session would ask
// them to switch it on three thousand times — which is why
// `reset_view_state` deliberately leaves it alone while emptying the
// histogram beside it.
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
let display = display.clone();
let chosen_peaking = chosen_peaking.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return };
@@ -1530,6 +1550,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// arrival takes.
spots_ui::sync_panel(window, s);
// TRACES: FR-CULL-3
// The session owns the pass and the interface owns the choice, so
// they are joined here — on the one path every frame takes, which
// is also what makes a photograph opened with peaking already on
// arrive with its marks rather than without them.
if s.peaking() != chosen_peaking.get() {
s.set_peaking(chosen_peaking.get());
}
window.set_peaking_available(s.peaking_available());
let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.**
@@ -1592,6 +1622,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.map_or_else(histogram::empty, histogram::view),
);
}
// TRACES: FR-CULL-3 | NFR-P14
// **Marked on the settled frame and no other**, and unlike
// the histogram beside it the marks are taken *down* in
// between rather than left standing.
//
// The reason is not budget — the dispatch is a fraction of
// a millisecond and would fit inside a draft frame
// comfortably. It is that peaking measures the top octave
// of the frame it is given, and a draft frame is rendered
// at half resolution: a defocused edge that spans four
// pixels there spans two, which is the signature of a
// sharp one. Measuring it would mark the out-of-focus
// background of every photograph, briefly, during every
// drag. A stale overlay is no better, because a pan moves
// the picture out from under it.
//
// So the marks pause while a control is moving and return
// when it stops, which the panel says out loud rather than
// leaving to be discovered.
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
match overlay {
Some(image) => {
window.set_focus_overlay(image);
window.set_focus_overlay_ready(true);
}
None => window.set_focus_overlay_ready(false),
}
}
Err(e) => {
log::warn!("render failed: {e}");
@@ -1599,6 +1657,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
// TRACES: FR-CULL-3
// And nothing to mark. Focus marks over the last frame
// that rendered, beside a message saying this one did not,
// is the same confident lie in a second instrument.
window.set_focus_overlay_ready(false);
}
}
})
@@ -2837,6 +2900,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// TRACES: FR-CULL-3
// The peaking switch and its two choices.
//
// All three write `chosen_peaking` and then redraw, because the marks are
// produced by a compute pass over the rendered frame: there is nothing the
// interface can change about the overlay that does not require the frame
// to be measured again. Turning peaking *off* redraws for the same reason
// — that render is what drops the overlay textures and clears the flag.
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_toggled(move |on| {
let Some(w) = weak.upgrade() else { return };
// Built from the chips as they currently stand rather than from a
// remembered value: they are what the photographer can see, and an
// overlay that came back in a configuration the panel is not
// showing would be the panel lying about itself.
let next = on.then(|| dr_gpu::FocusPeaking {
sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()),
colour: peaking::colour(w.get_peaking_colour()),
});
chosen.set(next);
w.set_peaking_on(next.is_some());
redraw(&w);
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_sensitivity_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_sensitivity(index);
// Only reachable while peaking is on — the chips are not drawn
// otherwise — but written as a conditional rather than an
// `expect`, because a panel is free to change its mind about that
// and nothing here should fall over when it does.
if let Some(mut current) = chosen.get() {
current.sensitivity = peaking::sensitivity(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
{
let weak = window.as_weak();
let chosen = chosen_peaking.clone();
let redraw = redraw.clone();
window.on_peaking_colour_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
w.set_peaking_colour(index);
if let Some(mut current) = chosen.get() {
current.colour = peaking::colour(index);
chosen.set(Some(current));
redraw(&w);
}
});
}
// The chips open on whatever the vocabulary calls its default, so the
// panel and the pass agree before anything has been pressed.
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
window.set_peaking_colour(peaking::colour_index(Default::default()));
// TRACES: FR-DSP-8 | FR-DSP-6
// And which display that canvas is on, from now until the window closes.
display_ui::attach(&window, &display, &viewport, redraw.clone());
+160
View File
@@ -0,0 +1,160 @@
//! TRACES: FR-CULL-3
//! The focus-peaking vocabulary, as the indices a chip row can carry.
//!
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
//! marks. This decides how a menu of three sensitivities and four colours
//! crosses the boundary into Slint, which has no notion of a Rust enum and
//! carries the choice as an `int` into an array of labels.
//!
//! That translation is small and it is the kind of small that goes wrong
//! silently. An index the interface sends that Rust reads as a different
//! variant produces a control that changes something other than what it says,
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
//! So the order lives in one place here, both directions are asserted to round
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
//! the same as the vocabularies they claim to name.
//!
//! Free-standing functions over plain integers, deliberately, for the reason
//! `crate::histogram` gives: none of this needs a GPU, a window or a
//! photograph to be checked, and all of it is invisible when wrong.
use dr_gpu::{PeakColour, PeakSensitivity};
/// The sensitivities, in the order the chip row shows them.
///
/// Least sensitive first, so the row reads left to right as "mark less" to
/// "mark more" — the axis the photographer is actually moving along.
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
PeakSensitivity::Low,
PeakSensitivity::Medium,
PeakSensitivity::High,
];
/// The mark colours, in the order the chip row shows them.
pub(crate) const COLOURS: [PeakColour; 4] = [
PeakColour::Red,
PeakColour::Yellow,
PeakColour::Cyan,
PeakColour::Magenta,
];
/// The sensitivity an index names.
///
/// Out of range falls back to the default rather than panicking. The index
/// arrives from the interface, and the interface is the half of this that can
/// be recompiled without recompiling the other — a chip row that grew an entry
/// should degrade to a sane setting, not take the application down mid-cull.
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
usize::try_from(index)
.ok()
.and_then(|i| SENSITIVITIES.get(i).copied())
.unwrap_or_default()
}
/// The colour an index names, on the same terms.
pub(crate) fn colour(index: i32) -> PeakColour {
usize::try_from(index)
.ok()
.and_then(|i| COLOURS.get(i).copied())
.unwrap_or_default()
}
/// Which chip is lit for this sensitivity.
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
}
/// Which chip is lit for this colour.
pub(crate) fn colour_index(value: PeakColour) -> i32 {
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_variant_appears_exactly_once_in_its_row() {
// A variant missing from the row is a setting the photographer cannot
// reach; one listed twice is two chips that do the same thing, of
// which only the first can ever look selected. Both are invisible in
// the running application until somebody presses the wrong chip.
for s in SENSITIVITIES {
assert_eq!(
SENSITIVITIES.iter().filter(|x| **x == s).count(),
1,
"{s:?} is listed more than once"
);
}
for c in COLOURS {
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
}
// Named rather than counted, so adding a variant to `dr_gpu` without
// adding it here fails to compile instead of passing quietly.
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
assert!(COLOURS.contains(&PeakColour::Red));
assert!(COLOURS.contains(&PeakColour::Yellow));
assert!(COLOURS.contains(&PeakColour::Cyan));
assert!(COLOURS.contains(&PeakColour::Magenta));
}
#[test]
fn an_index_and_its_variant_agree_in_both_directions() {
// The failure this catches is a chip that lights up under the pointer
// while a different setting takes effect — the two directions drifting
// apart is exactly what one shared array is here to prevent, and the
// only way to see it is to go round.
for (i, s) in SENSITIVITIES.iter().enumerate() {
assert_eq!(sensitivity(i as i32), *s);
assert_eq!(sensitivity_index(*s), i as i32);
}
for (i, c) in COLOURS.iter().enumerate() {
assert_eq!(colour(i as i32), *c);
assert_eq!(colour_index(*c), i as i32);
}
}
#[test]
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
// Slint has no bound on the `int` it sends and Rust has no way to
// refuse one. A panic here would be an application that closes because
// a chip row was edited.
assert_eq!(sensitivity(-1), PeakSensitivity::default());
assert_eq!(sensitivity(99), PeakSensitivity::default());
assert_eq!(colour(-1), PeakColour::default());
assert_eq!(colour(99), PeakColour::default());
}
#[test]
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
// **The one seam neither compiler checks.** The labels live in
// `ui/peaking.slint` and the meanings live here, joined only by an
// integer; a fifth colour added to the chip row would send index 4 to
// `colour`, which would quietly answer Red. Reading the file is
// clumsier than a derive, and it is what there is.
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
.expect("the panel this module serves");
let listed = |line_start: &str| -> usize {
let line = src
.lines()
.map(str::trim)
.find(|l| l.starts_with(line_start))
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
line.matches('"').count() / 2
};
assert_eq!(
listed("options: [\"Low\""),
SENSITIVITIES.len(),
"the sensitivity chips and `SENSITIVITIES` disagree"
);
assert_eq!(
listed("options: [\"Red\""),
COLOURS.len(),
"the colour chips and `COLOURS` disagree"
);
}
}
+49
View File
@@ -11,6 +11,7 @@ import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, Progres
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { PresetSheet } from "presets.slint";
import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
@@ -71,6 +72,22 @@ export component AppWindow inherits Window {
/// of a draft frame is a histogram of an image nobody is reading.
in property <HistogramView> histogram;
/// TRACES: FR-CULL-3
/// Focus peaking: the marks, whether they describe *this* frame, and the
/// three things the photographer chose. All of them are Rust's, because
/// the marks come from a compute pass — see `peaking.slint` for why
/// `focus-overlay-ready` is a separate question from `peaking-on`.
in property <image> focus-overlay;
in property <bool> focus-overlay-ready: false;
in property <bool> peaking-on: false;
in property <bool> peaking-available: true;
in property <int> peaking-sensitivity: 1;
in property <int> peaking-colour: 0;
callback peaking-toggled(bool);
callback peaking-sensitivity-picked(int);
callback peaking-colour-picked(int);
// --- zoom, pan and crop (FR-DEV-4) ---
//
// Zoom is a *viewing* state, not an edit: it changes the resolution the
@@ -1656,6 +1673,18 @@ in property <bool> panel-visible: true;
image-rendering: ImageRendering.pixelated;
}
// TRACES: FR-CULL-3
// The focus marks, over the same fitted rect. See
// `peaking.slint` for why they are a layer over the canvas
// rather than a tint in it.
if root.focus-overlay-ready && root.total > 0: FocusMarks {
x: parent.shown-x;
y: parent.shown-y;
width: parent.shown-w;
height: parent.shown-h;
marks: root.focus-overlay;
}
// Where the photograph actually sits inside this box.
//
// `image-fit: contain` letterboxes, and Slint does not report
@@ -2189,6 +2218,26 @@ in property <bool> panel-visible: true;
background: Theme.rule;
}
// TRACES: FR-CULL-3
// Under the histogram, because the two are the same
// kind of thing: instruments that report on the
// photograph rather than change it. Kept in every
// mode for the same reason the histogram is.
FocusPanel {
available: root.peaking-available;
showing: root.peaking-on;
sensitivity: root.peaking-sensitivity;
colour: root.peaking-colour;
toggled(v) => { root.peaking-toggled(v); }
sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); }
colour-picked(i) => { root.peaking-colour-picked(i); }
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// Framing above the colour work, matching how the edit is
// made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see
+150
View File
@@ -0,0 +1,150 @@
// TRACES: FR-CULL-3
// The focus-peaking switch, and the two choices it exposes.
//
// **An instrument, not an operation**, exactly as the histogram above it is:
// it has no parameters in the edit graph, changes nothing about the
// photograph, and answers a question rather than asking one. So it is written
// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled
// by it — nothing here names an operation or reads a parameter out of one.
//
// **Both choices are words, not swatches.** The colour picker is the obvious
// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to:
// a control for choosing between hues, presented only as hues, is unusable by
// the person most likely to need to change it. The chips say "Red" and "Cyan".
//
// **Why the two chip rows only exist while peaking is on.** They are settings
// for something that is not happening, and the develop column is the
// photographer's instrument panel — every row it holds is a slider pushed
// below the fold. The panel's own height is bound to its content, so the
// column reflows rather than leaving a gap.
import { Theme } from "theme.slint";
import { Button, PanelHeading, Caption } from "widgets.slint";
import { Segmented } from "controls.slint";
// TRACES: FR-CULL-3
// The marks themselves, composited over the canvas.
//
// **A layer over the photograph and not a tint in it**, which is the same rule
// `app.slint` states on the region map: a diagnostic "must not reach the
// histogram, an export, or the texture the develop pass hands the compositor".
// `HistogramPass` counts whatever the develop pass last rendered, so marks
// painted into that frame would arrive in the histogram as a spike and in the
// clipping figure as blown highlights. The layer is transparent everywhere
// except where something is in focus.
//
// **No `source-clip` and no rotation**, unlike the region map. That is a
// source-space picture being windowed down to the visible part; this was
// measured on the rendered frame itself, so it is already cropped, zoomed and
// turned exactly as the canvas is. One fewer thing that can drift out of
// registration.
//
// The caller places it on `canvas-area`'s fitted rect, which is the shared
// contract for anything that lands on the picture.
export component FocusMarks inherits Image {
/// The overlay `DevelopSession::focus_overlay` produced for this frame.
in property <image> marks;
source: root.marks;
image-fit: fill;
// Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it
// into a grey haze that reads as softness — the opposite of what it is
// reporting.
image-rendering: ImageRendering.pixelated;
}
// TRACES: FR-CULL-3
export component FocusPanel inherits Rectangle {
/// Whether this device could build the overlay at all.
///
/// A compute pass can fail to compile on a driver nobody here has, and the
/// honest response is to say so rather than to offer a switch that does
/// nothing when pressed. The develop view keeps working without it; only
/// this panel changes.
in property <bool> available: true;
/// Whether the overlay is currently being drawn.
///
/// `showing` rather than the obvious `on`: Slint has no reserved word
/// there today, and a one-word property that might become one is not worth
/// the bet on a panel this small.
in property <bool> showing: false;
/// Index into `PeakSensitivity`, in the order Rust declares it.
in property <int> sensitivity: 1;
/// Index into `PeakColour`, likewise.
in property <int> colour: 0;
callback toggled(bool);
callback sensitivity-picked(int);
callback colour-picked(int);
background: Theme.surface;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it clips itself. The develop
/// column is the largest of these and nothing else; see `SpotPanel` and
/// `HistogramPanel` for the whole protocol.
///
/// Both chip rows wrap at three, which is what keeps this number at the
/// narrowest column the application supports rather than at four chips
/// abreast — a single row of four would set the width of the entire
/// sidebar for every other panel in it.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
// give: a nested conditional layout under-reports its height here and the
// rows below it get drawn on top of one another. Every row carries its own
// `if`.
layout := VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
PanelHeading { text: "FOCUS"; }
if !root.available: Caption {
text: "This device could not build the overlay.";
wrap: word-wrap;
}
if root.available: Button {
// The label states the action rather than the state, as the mask
// overlay's does: a photographer reads a button for what pressing
// it will do.
text: root.showing ? "Hide focus peaking" : "Show focus peaking";
active: root.showing;
clicked => { root.toggled(!root.showing); }
}
if root.available && root.showing: Segmented {
label: "Sensitivity";
hint: "lower on a noisy frame";
options: ["Low", "Medium", "High"];
selected: root.sensitivity;
columns: 3;
picked(i) => { root.sensitivity-picked(i); }
}
if root.available && root.showing: Segmented {
label: "Marks";
hint: "pick what the subject is not";
options: ["Red", "Yellow", "Cyan", "Magenta"];
selected: root.colour;
columns: 3;
picked(i) => { root.colour-picked(i); }
}
if root.available && root.showing: Caption {
// Said once, here, rather than left to be discovered: the marks go
// away while a control is moving because a half-resolution draft
// frame cannot be measured for sharpness (see `FocusPeakPass`).
//
// Kept to one short sentence on purpose. A wrapping Text reports
// its *unwrapped* width as its preferred one, and this panel's
// `content-width` is what the develop column sizes itself from —
// a paragraph here would hold the whole sidebar open.
text: "Marks pause while a control is dragged.";
wrap: word-wrap;
}
}
}