Files
DarkRoom/ui/dr-ui/src/peaking.rs
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

161 lines
6.5 KiB
Rust

//! 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"
);
}
}