Files
DarkRoom/ui/dr-ui/ui/peaking.slint
T
dtourolleandClaude Opus 5 2168cdd1c4 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>
2026-08-29 21:43:08 +02:00

151 lines
6.5 KiB
Plaintext

// 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;
}
}
}