Mark what is in focus, so a frame can be judged without zooming to 100%

FR-CULL-3's focus peaking. One compute dispatch measures local contrast
in WGSL and writes an overlay texture; on desktop it reaches Slint
through the same zero-copy wgpu import the canvas uses, so nothing
per-pixel touches the CPU on the frame path.

With peaking off the cost is zero and structurally so: focus_overlay
opens with `let settings = self.peaking?;` before the frame is touched,
and clearing drops both overlay textures, so no VRAM is held either.

NFR-P14 is met by construction rather than by measurement -- one
dispatch, no second render, no pipeline compile after session open, and
a test asserting allocations stay at 2 over eight frames. The budget
test asserts 50ms at 4K rather than a tight bound, deliberately: a tight
bound fails on a loaded machine and gets deleted, which is worse than a
loose one that still catches the regression that matters.

TD-1 is amended rather than joined by a TD-6: on Android the overlay
rides the readback that already exists there, roughly doubling that
transfer while peaking is on, and TD-1's own "Done when" removes both
because both are the same missing capability.

Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings
green, which also compiles peaking.slint through dr-ui's build.rs; 11
focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass.
Not verified: the cfg(target_os = "android") arm, which the host-target
clippy never compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 21:43:08 +02:00
co-authored by Claude Opus 5
parent 5133e53bc8
commit 2168cdd1c4
9 changed files with 1781 additions and 1 deletions
+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 presets;
mod remote;
mod segmentation;
@@ -316,6 +317,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
@@ -1464,11 +1471,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 };
@@ -1529,6 +1549,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.**
@@ -1591,6 +1621,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}");
@@ -1598,6 +1656,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);
}
}
})
@@ -2818,6 +2881,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
@@ -10,6 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
@@ -70,6 +71,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
@@ -1617,6 +1634,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
@@ -2150,6 +2179,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;
}
}
}