Merge branch 'histogram'

# Conflicts:
#	ui/dr-ui/src/lib.rs
This commit is contained in:
2026-08-17 10:00:27 +02:00
11 changed files with 1541 additions and 46 deletions
+119 -1
View File
@@ -10,7 +10,7 @@
//! new operation appears in the panel with no change here (FR-DEV-3c).
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
@@ -34,6 +34,12 @@ pub struct DevelopSession {
history: History,
demosaiced: DemosaicedImage,
adjust: AdjustPass,
/// TRACES: FR-DSP-7
/// Optional, because a session that cannot count its frames is still a
/// session that can develop them. If the reduction fails to build — an
/// old driver, a device without the storage-buffer atomics it needs — the
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
}
impl DevelopSession {
@@ -88,6 +94,9 @@ impl DevelopSession {
history,
demosaiced,
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
}
}
@@ -551,6 +560,34 @@ impl DevelopSession {
Ok(slint::Image::from_rgba8(buffer))
}
/// TRACES: FR-DSP-7
/// Count the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced rather than rendering
/// its own.** The histogram has to describe what the photographer is
/// looking at, and rendering a second time to count it would both cost a
/// second pass and open the possibility of the two disagreeing.
///
/// That the frame is the *displayed* one has two consequences worth being
/// explicit about. It is in the output colour space, which is what
/// FR-DSP-7 asks for — the levels counted are the levels the display will
/// show, so a clipped bin means a highlight that is actually gone rather
/// than one the transform might still recover. And when the view is zoomed
/// or cropped it describes the visible region, not the whole file: a
/// photographer inspecting a highlight at 4× is asking about *that*
/// highlight, and a histogram of the parts of the frame off screen would
/// be answering a question nobody asked.
///
/// `None` where nothing has been rendered yet, or where the device could
/// not build the reduction.
pub fn histogram(&self) -> Option<Histogram> {
let pass = self.histogram.as_ref()?;
let frame = self.adjust.output()?;
pass.compute(frame)
.inspect_err(|e| log::warn!("histogram failed: {e}"))
.ok()
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
@@ -1944,6 +1981,87 @@ mod tests {
}
}
/// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of
/// them so.
fn split_frame(size: u32) -> Vec<u8> {
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
for _ in 0..size {
for x in 0..size {
let v = if x < size / 2 { 0u8 } else { 255 };
rgba.extend_from_slice(&[v, v, v, 255]);
}
}
rgba
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
// The wiring, end to end and against exact numbers: a 64x64 frame that
// is half black and half white must come back as 2048 pixels at level
// 0, 2048 at 255, and both clipping counters at 2048.
//
// Asserted at the session rather than at the pass because the mistake
// this catches is not arithmetic — `dr_gpu` has its own tests for that
// — it is counting the *wrong texture*. Reading a stale target, or the
// demosaiced source instead of the adjusted output, produces a
// perfectly well-formed histogram of an image the photographer is not
// looking at, which is the one failure mode that cannot be seen.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.render(64, 64).expect("render");
let hist = session.histogram().expect("a rendered session must count");
assert_eq!(hist.pixels(), 64 * 64);
assert_eq!(hist.red()[0], 2048, "the black half");
assert_eq!(hist.red()[255], 2048, "the white half");
assert_eq!(hist.clipped_shadows(), 2048);
assert_eq!(hist.clipped_highlights(), 2048);
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_follows_the_edit_rather_than_the_file() {
// The property that makes it *live*. A histogram computed once from the
// source would pass the test above and be useless — the whole reason
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
// away the white half must leave a histogram with no white in it and
// no highlight clipping to report.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
session.render(64, 64).expect("render");
let hist = session.histogram().expect("histogram");
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
assert_eq!(hist.red()[0], 32 * 64);
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
assert_eq!(hist.clipped_highlights(), 0);
assert_eq!(hist.clipped_shadows(), 32 * 64);
}
#[test]
fn routing_indices_map_back_to_the_right_parameter() {
// A wrong index would silently move the wrong slider's value, which
+330
View File
@@ -0,0 +1,330 @@
//! TRACES: FR-DSP-7
//! Turning bin counts into something a 280-pixel column can be read from.
//!
//! `dr_gpu` counts; this decides what the counting looks like. The split is
//! ARCH §4.3a's: how many columns a panel can show, which peak to scale
//! against and when a handful of specular pixels is worth an alarm are all
//! questions about *this* interface, and none of them belong beside the shader.
//!
//! Free-standing functions over plain numbers, deliberately. Every judgement
//! here is one that shows up as a wrong picture rather than as a crash — a
//! histogram flattened by a black-point spike, a clipping figure that reads
//! 0.0% when a quarter of the sky is gone — and none of them can be checked by
//! looking at a running application, because a plausible wrong shape and the
//! right shape look equally plausible. So they are all reachable without a GPU,
//! a window or a photograph.
use dr_gpu::{Histogram, HISTOGRAM_BINS};
use crate::HistogramView;
/// Columns the plot draws.
///
/// **A divisor of [`HISTOGRAM_BINS`], and that is not a detail.** 96 columns
/// over 256 bins folds two levels into some columns and three into others, so a
/// perfectly even ramp is drawn as a comb — structure the photograph does not
/// have. Four levels per column, always.
///
/// 64 rather than 128 because the plot is about 256px wide in the develop
/// column: four pixels per column is a bar that can be seen, where two is a
/// hairline. It also halves the repeater, and this panel is instantiated for
/// the life of the window rather than built when it is looked at.
pub(crate) const COLUMNS: usize = 64;
/// Fraction of the frame that must clip before the indicator lights.
///
/// Not zero. Almost every photograph has a few pixels at an extreme — a
/// specular glint on chrome, a sensor hot pixel, the dark corner of a vignette
/// — and an indicator that fires on all of them is one a photographer stops
/// reading within a day. A thousandth of the frame is roughly where clipping
/// stops being an artefact and starts being a decision.
const CLIP_VISIBLE: f32 = 0.001;
/// The whole panel's state, for one counted frame.
pub(crate) fn view(hist: &Histogram) -> HistogramView {
HistogramView {
// Zero pixels means nothing has been rendered yet, not an empty
// photograph — the panel draws its frame and no data rather than a flat
// line, which would be a claim about an image that does not exist.
available: hist.pixels() > 0,
luma: model(&scaled(hist.luma())),
red: model(&scaled(hist.red())),
green: model(&scaled(hist.green())),
blue: model(&scaled(hist.blue())),
highlights_clipped: fraction(hist.clipped_highlights(), hist.pixels()) >= CLIP_VISIBLE,
shadows_clipped: fraction(hist.clipped_shadows(), hist.pixels()) >= CLIP_VISIBLE,
highlights_label: percentage(hist.clipped_highlights(), hist.pixels()).into(),
shadows_label: percentage(hist.clipped_shadows(), hist.pixels()).into(),
}
}
/// An empty panel — no image open, or a frame that could not be counted.
pub(crate) fn empty() -> HistogramView {
HistogramView {
available: false,
luma: model(&[0.0; COLUMNS]),
red: model(&[0.0; COLUMNS]),
green: model(&[0.0; COLUMNS]),
blue: model(&[0.0; COLUMNS]),
highlights_clipped: false,
shadows_clipped: false,
// Not "0%". Nothing has been counted, and claiming no clipping about a
// frame that does not exist is the one thing an instrument must not do.
highlights_label: UNKNOWN.into(),
shadows_label: UNKNOWN.into(),
}
}
/// What a figure reads before there is a frame behind it.
const UNKNOWN: &str = "—";
fn model(heights: &[f32; COLUMNS]) -> slint::ModelRc<f32> {
slint::ModelRc::new(slint::VecModel::from(heights.to_vec()))
}
/// Fold 256 levels into [`COLUMNS`] columns and scale to 0..1.
pub(crate) fn scaled(bins: &[u32; HISTOGRAM_BINS]) -> [f32; COLUMNS] {
let folded = fold(bins);
let peak = peak(&folded);
if peak == 0 {
return [0.0; COLUMNS];
}
let mut out = [0.0f32; COLUMNS];
for (o, count) in out.iter_mut().zip(folded.iter()) {
// Clamped because `peak` deliberately ignores the end columns, so the
// spike at a clipped white is taller than the scale it is drawn on.
*o = (*count as f32 / peak as f32).min(1.0);
}
out
}
/// Sum adjacent levels into the columns the plot has room for.
fn fold(bins: &[u32; HISTOGRAM_BINS]) -> [u32; COLUMNS] {
const PER_COLUMN: usize = HISTOGRAM_BINS / COLUMNS;
let mut out = [0u32; COLUMNS];
for (level, count) in bins.iter().enumerate() {
// Saturating: a 24 MP frame of one colour is 24 million in one bin,
// which fits, but two folded bins of a hypothetical larger frame need
// not — and a histogram that wrapped to near-zero at the exact moment
// the image went flat would be worse than useless.
out[level / PER_COLUMN] = out[level / PER_COLUMN].saturating_add(*count);
}
out
}
/// The count the plot's full height stands for.
///
/// **The end columns are excluded, and this is the single most consequential
/// decision in the file.** A photograph shot against a black backdrop puts a
/// third of its pixels in level 0; scaled against that, every tone the
/// photographer is actually working with is drawn two pixels tall and the
/// histogram says nothing. The same happens at the top with a blown sky. Both
/// spikes are exactly what the clipping indicators report separately and in
/// figures, so nothing is hidden by leaving them off the scale — the plot stops
/// being dominated by the one fact it was already stating twice.
///
/// Falls back to the true maximum when the ends are all there is, so a frame
/// that really is entirely black still draws something rather than nothing.
fn peak(folded: &[u32; COLUMNS]) -> u32 {
let interior = folded[1..COLUMNS - 1].iter().copied().max().unwrap_or(0);
if interior > 0 {
interior
} else {
folded.iter().copied().max().unwrap_or(0)
}
}
fn fraction(clipped: u32, pixels: u32) -> f32 {
if pixels == 0 {
0.0
} else {
clipped as f32 / pixels as f32
}
}
/// How much of the frame is gone, as a figure rather than a colour.
///
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
/// text half of that: the marker beside it says *whether*, and this says how
/// much — which is the more useful half anyway, since the choice between
/// pulling a stop back and leaving a specular highlight alone is a choice about
/// magnitude.
///
/// Distinguishes "none" from "not none but under a tenth of a percent": those
/// are different answers, and rounding the second to `0.0%` would tell a
/// photographer their highlights were safe when the indicator beside it is lit.
fn percentage(clipped: u32, pixels: u32) -> String {
if pixels == 0 || clipped == 0 {
return "0%".into();
}
let pct = 100.0 * fraction(clipped, pixels);
if pct < 0.1 {
"<0.1%".into()
} else {
format!("{pct:.1}%")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A histogram with `count` pixels at each named level and nothing else.
///
/// Built through the GPU pass's own constructor path would need a device;
/// these are assertions about arithmetic, so they take the counts directly.
fn bins(levels: &[(usize, u32)]) -> [u32; HISTOGRAM_BINS] {
let mut out = [0u32; HISTOGRAM_BINS];
for (level, count) in levels {
out[*level] = *count;
}
out
}
#[test]
fn every_column_covers_the_same_number_of_levels() {
// The comb bug. With a column count that does not divide 256, some
// columns gather three levels and some two, so a perfectly even ramp is
// drawn with every third bar 50% taller — which reads as structure in
// the image that is not there. Asserted on the fold rather than on the
// constant, because the constant being wrong is only a problem through
// what the fold then does with it.
assert_eq!(HISTOGRAM_BINS % COLUMNS, 0);
let flat = [7u32; HISTOGRAM_BINS];
let folded = fold(&flat);
let first = folded[0];
assert!(first > 0);
assert!(
folded.iter().all(|c| *c == first),
"an even distribution must fold to even columns, got {folded:?}"
);
}
#[test]
fn a_level_lands_in_the_column_that_covers_it() {
// Routing, level by level. An off-by-one here draws the whole
// photograph one column left of where it belongs, which is invisible on
// any image and wrong on all of them.
let per = HISTOGRAM_BINS / COLUMNS;
for level in [0usize, 1, per, per + 1, HISTOGRAM_BINS - 1] {
let folded = fold(&bins(&[(level, 5)]));
let expected = level / per;
assert_eq!(
folded[expected], 5,
"level {level} missed column {expected}"
);
assert_eq!(
folded.iter().sum::<u32>(),
5,
"level {level} was counted in more than one column"
);
}
}
#[test]
fn a_clipped_spike_does_not_flatten_everything_else() {
// The reason `peak` ignores the ends. A frame that is nine-tenths pure
// black — a studio shot on a black backdrop, or any night scene — must
// still show the tones the photographer is working on. Scaled against
// the black spike they would be a hundredth of the plot's height, which
// is a flat line.
let mut levels = vec![(0usize, 900_000u32)];
levels.push((128, 1000));
levels.push((129, 500));
let heights = scaled(&bins(&levels));
let mid = heights[128 / (HISTOGRAM_BINS / COLUMNS)];
assert!(
mid > 0.9,
"the tallest interior column should reach the top, got {mid}"
);
// And the spike is still drawn, at the ceiling rather than off it.
assert_eq!(heights[0], 1.0);
}
#[test]
fn an_entirely_black_frame_still_draws_its_spike() {
// The fallback in `peak`. With every pixel at level 0 the interior is
// empty, and dividing by that peak would either panic or produce a
// plot with nothing in it — which says "no data" about a frame that has
// a great deal of data, all of it bad news.
let heights = scaled(&bins(&[(0, 4096)]));
assert_eq!(heights[0], 1.0);
assert!(heights[1..].iter().all(|h| *h == 0.0));
}
#[test]
fn an_empty_frame_scales_to_nothing_rather_than_dividing_by_zero() {
let heights = scaled(&[0u32; HISTOGRAM_BINS]);
assert!(heights.iter().all(|h| *h == 0.0));
}
#[test]
fn heights_never_leave_the_plot() {
// Everything downstream multiplies these by the plot's height, so a
// value above 1 paints outside the box and one below 0 paints upward
// out of the panel. Checked across the shapes most likely to break it:
// all the weight at one end, and all of it in one interior column.
for levels in [
vec![(0usize, 100u32)],
vec![(255, 100)],
vec![(0, 100), (255, 100)],
vec![(64, 100)],
vec![(0, 1_000_000), (64, 1)],
] {
for h in scaled(&bins(&levels)) {
assert!((0.0..=1.0).contains(&h), "height {h} left the plot");
}
}
}
#[test]
fn folding_cannot_overflow_on_a_large_frame() {
// `u32` counts summed two at a time. A saturating add rather than a
// wrapping one, because the wrap would land near zero — a histogram
// that emptied itself at the exact moment the image became flat.
let folded = fold(&[u32::MAX; HISTOGRAM_BINS]);
assert_eq!(folded[0], u32::MAX);
}
#[test]
fn a_clipping_figure_distinguishes_none_from_nearly_none() {
// `0.0%` and `<0.1%` are different answers, and the second is the one
// that appears beside a lit marker. Rounding it to the first would have
// the panel contradict itself: an indicator saying "clipped" over a
// figure saying "none".
assert_eq!(percentage(0, 1000), "0%");
assert_eq!(percentage(1, 1_000_000), "<0.1%");
assert_eq!(percentage(12, 1000), "1.2%");
assert_eq!(percentage(1000, 1000), "100.0%");
// No frame yet: not a claim that nothing is clipped so much as nothing
// to claim, and "0%" is the honest reading of zero pixels counted.
assert_eq!(percentage(0, 0), "0%");
}
#[test]
fn an_uncounted_frame_says_it_does_not_know_rather_than_says_zero() {
// The panel is emptied when an image is closed and again when one fails
// to render, and it stays empty until the first settled frame. Reading
// "Highlights 0%" through that gap is a positive claim about a
// photograph nobody has counted — the failure mode of an instrument
// that is worse than no instrument.
let blank = empty();
assert!(!blank.available);
assert_eq!(blank.highlights_label, UNKNOWN);
assert_eq!(blank.shadows_label, UNKNOWN);
assert!(!blank.highlights_clipped && !blank.shadows_clipped);
}
#[test]
fn the_indicator_ignores_a_handful_of_specular_pixels() {
// The threshold, from both sides. Below it the marker stays dark while
// the figure still reports what it found — an indicator that fired on
// every glint would be one nobody reads, and one that hid the number
// would be one nobody could check.
let just_under = (CLIP_VISIBLE * 1_000_000.0) as u32 - 1;
assert!(fraction(just_under, 1_000_000) < CLIP_VISIBLE);
assert!(fraction(just_under + 1, 1_000_000) >= CLIP_VISIBLE);
}
}
+28
View File
@@ -19,6 +19,7 @@ mod collections_ui;
mod derived_sync;
mod develop;
mod export;
mod histogram;
mod labels;
mod library;
mod library_ui;
@@ -261,6 +262,11 @@ fn reset_view_state(window: &AppWindow) {
// previous image's history.
window.set_can_undo(false);
window.set_can_redo(false);
// TRACES: FR-DSP-7
// Emptied rather than left standing: the previous photograph's histogram
// 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());
}
/// Push the framing back to the geometry panel.
@@ -1016,10 +1022,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// duration of every gesture.
let (vw, vh) = *viewport.borrow();
window.set_magnified(s.magnifies_source(vw, vh));
// TRACES: FR-DSP-7
// **Counted on the settled frame and no other.**
//
// FR-DSP-7 requires the histogram not extend the FR-DSP-3
// frame budget, and `draft` is already exactly the flag
// that says a gesture is still moving — so this reduction
// and its 4 KB transfer happen once when the slider stops
// rather than on every one of the forty frames a drag
// emits. Nothing is lost by it: a histogram of a
// half-resolution frame flickering past under a finger is
// not a reading anyone takes.
if !draft {
window.set_histogram(
s.histogram()
.as_ref()
.map_or_else(histogram::empty, histogram::view),
);
}
}
Err(e) => {
log::warn!("render failed: {e}");
window.set_load_error(e.into());
// No frame, so nothing to describe. The stale plot would
// otherwise sit beside the error message looking current.
window.set_histogram(histogram::empty());
}
}
})
+25
View File
@@ -117,6 +117,31 @@ colors:
state, and it is worth the one exception to say that instantly. Muted
rather than saturated so it does not shift perception of a nearby image.
_plot:
section: plot series
note: |
The histogram's four traces (FR-DSP-7). Hue here is the same exception the
swatch takes and not a second one: a per-channel histogram has to say
*which channel*, and no achromatic treatment can distinguish red from blue
— so the colour is data, exactly as the image beside it is.
Held well back from full strength, and darker than `ink`, for the reason
the theme preamble gives: three saturated traces sitting a few centimetres
from the photograph would compete with it and shift how its colours read.
These are legible against `ground` at a glance and no louder than that.
plot-red: "#C4626A"
plot-green: "#6BA867"
plot-blue: "#5F8CCB"
plot-luma:
value: "#4E5257"
doc: |
The luminance trace, drawn filled and behind the three channels. Neutral
because luminance is not a channel — it is the axis exposure is read off,
and a hue would imply it were one series among four rather than the one
the other three are decomposing.
lengths:
_gaps: { break: true }
gap-sm: 6
+22 -1
View File
@@ -4,9 +4,10 @@ import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
import { CollectionsPanel, CollectionRow } from "collections.slint";
import { HistogramPanel, HistogramView } from "histogram.slint";
import { SettingsPage } from "settings.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow }
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
// Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on
@@ -223,6 +224,11 @@ export component AppWindow inherits Window {
in property <int> total: 0;
in property <string> load-error: "";
/// TRACES: FR-DSP-7
/// The counted frame. Set only when the render has settled — a histogram
/// of a draft frame is a histogram of an image nobody is reading.
in property <HistogramView> histogram;
// --- zoom, pan and crop (FR-DEV-4) ---
//
// Zoom is a *viewing* state, not an edit: it changes the resolution the
@@ -1500,6 +1506,21 @@ in property <bool> panel-visible: true;
background: Theme.rule;
}
// Directly under the capture metadata and above every
// control, because it is the thing the controls are
// judged against: exposure, blacks and whites are all
// set by watching this move (FR-DSP-7). An instrument
// below the sliders it reports on would have the
// photographer looking away from it to use it.
HistogramPanel {
data: root.histogram;
}
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
+248
View File
@@ -0,0 +1,248 @@
// TRACES: FR-DSP-7
// The live histogram, and what it says about clipping.
//
// **Why this is hand-built when the rest of the column is generated.** The
// develop panel is built from operation capabilities, and a histogram is not an
// operation: it has no parameters, changes nothing about the photograph, and
// answers a question rather than asking one. It is an *instrument* — the same
// kind of thing as the zoom readout — so it is written, and ARCH §4.3a is
// untroubled by it: nothing here reads a parameter out of a descriptor.
//
// **Everything numeric was decided in Rust.** The heights arriving here are
// already 0..1 against a chosen scale, and the clipping figures are already
// strings. That is not tidiness — it is the only way the arithmetic is
// testable. A peak chosen in Slint could only be checked by looking at it, and
// a histogram that is the wrong shape looks exactly as plausible as one that is
// right (see `src/histogram.rs`).
import { Theme } from "theme.slint";
import { PanelHeading, Caption, Panel } from "widgets.slint";
// One counted frame, ready to draw.
//
// Heights are 0..1 with y up, darkest level first — the same convention the
// tone curve's samples use, so the two plots in this column cannot end up
// disagreeing about which way is up.
export struct HistogramView {
// Whether a frame has been counted at all. Distinct from an all-zero
// histogram, which would be a claim about an image rather than the absence
// of one.
available: bool,
luma: [float],
red: [float],
green: [float],
blue: [float],
// Past the threshold worth reporting — see `CLIP_VISIBLE` in Rust.
highlights-clipped: bool,
shadows-clipped: bool,
// How much, as text. NFR-A11Y-3: the marker beside these says *whether*
// by hue and position, and these say *how much* in a form that survives
// being unable to tell the two apart.
highlights-label: string,
shadows-label: string,
}
// One series, as a run of columns.
//
// Two shapes from one component because the two differ only in where the column
// starts: the luminance trace is filled from the baseline, the three channels
// are a line. Splitting them into two components would duplicate the column
// arithmetic, which is the part that has to stay identical or the four series
// would no longer be plotted against the same axis.
//
// One thin Rectangle per column: Slint has no polyline primitive, and this is
// the same construction `CurveEditor` uses a few files away.
component Trace inherits Rectangle {
/// 0..1, y up.
in property <[float]> heights;
in property <color> ink;
/// Filled from the baseline rather than drawn as a line.
in property <bool> filled: false;
in property <float> shade: 1.0;
background: transparent;
for h[i] in root.heights: Rectangle {
// The next column's height, so a line segment can span the gap between
// them. Without this a steep edge draws as a dotted stair rather than a
// rising line — the same fix `CurveEditor` makes for the same reason.
property <float> next: i + 1 < root.heights.length
? root.heights[i + 1] : h;
x: parent.width * i / max(root.heights.length, 1);
width: parent.width / max(root.heights.length, 1) + 1px;
y: root.filled
? parent.height * (1.0 - h)
: parent.height * (1.0 - max(h, self.next));
height: root.filled
? parent.height * h
// A floor, so a flat stretch of the trace is still a line and not a
// gap in one.
: max(parent.height * abs(self.next - h), 1.5px);
background: root.ink;
opacity: root.shade;
}
}
// A clipping readout: a lit marker and a figure.
//
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
// application is carried by hue alone, and clipping is the status most worth
// getting right — it is the one the histogram exists to warn about. So the
// marker appears and disappears (a shape, not a tint) and the figure states the
// magnitude in words. Either alone is enough to read it.
component ClipReadout inherits HorizontalLayout {
in property <string> title;
in property <string> figure;
in property <bool> lit;
spacing: Theme.gap-sm;
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 1px;
// `warn-ink`, the palette's one sanctioned hue: a blown highlight is a
// caution about the photograph, which is exactly the kind of thing that
// token exists to say instantly.
background: Theme.warn-ink;
visible: root.lit;
}
Caption { text: root.title; }
Caption { text: root.figure; warn: root.lit; }
}
// TRACES: FR-DSP-7
export component HistogramPanel inherits Rectangle {
in property <HistogramView> data;
background: transparent;
height: panel.preferred-height;
panel := Panel {
// Flat, like every panel in this column: the rule between it and its
// neighbour belongs to the column that stacks them.
flat: true;
width: 100%;
PanelHeading { text: "HISTOGRAM"; }
Rectangle {
// Tall enough to read a shape off and no taller. The column is the
// photographer's instrument panel, and every pixel this takes is a
// slider pushed below the fold.
height: 84px;
background: Theme.ground;
border-width: 1px;
border-color: Theme.rule;
// The traces are positioned by fraction of the plot and a value at
// the ceiling lands exactly on the border; clipping keeps the top
// row of pixels inside the box rather than over its edge.
clip: true;
// Quarter gridlines, so a tone can be placed on the axis without
// counting. Same construction and same weight as the tone curve's,
// because the two plots sit in one column and any difference
// between them would read as meaning something.
for i in [1, 2, 3]: Rectangle {
x: parent.width * i / 4;
width: 1px;
background: Theme.rule;
opacity: 0.5;
}
// Luminance first, so it sits behind: it is the envelope the three
// channels decompose, and a filled area drawn over a line hides it.
Trace {
width: 100%;
height: 100%;
heights: root.data.luma;
ink: Theme.plot-luma;
filled: true;
}
// The channels over it, held back so three overlapping traces stay
// legible where they cross — which on a neutral subject is
// everywhere.
Trace {
width: 100%;
height: 100%;
heights: root.data.red;
ink: Theme.plot-red;
shade: 0.85;
}
Trace {
width: 100%;
height: 100%;
heights: root.data.green;
ink: Theme.plot-green;
shade: 0.85;
}
Trace {
width: 100%;
height: 100%;
heights: root.data.blue;
ink: Theme.plot-blue;
shade: 0.85;
}
// The clipping markers, drawn on the plot's own ends.
//
// Here as well as in the figures below because this is where the
// eye already is: a bar standing at the edge the tones are piling
// against says which end has gone in the same glance that reads the
// shape, and the figures underneath say how much.
Rectangle {
x: 0;
width: 2px;
height: 100%;
background: Theme.warn-ink;
visible: root.data.shadows-clipped;
}
Rectangle {
x: parent.width - self.width;
width: 2px;
height: 100%;
background: Theme.warn-ink;
visible: root.data.highlights-clipped;
}
// Nothing rendered yet. Said rather than shown as an empty plot,
// which would be a claim that the photograph has no tones in it.
Caption {
text: "no frame yet";
width: 100%;
horizontal-alignment: center;
y: (parent.height - self.height) / 2;
visible: !root.data.available;
}
}
// Always drawn, never hidden. With no frame counted the figures read
// "—", which is a different statement from "0%" and the true one: the
// panel does not yet know. Hiding the row instead would move the
// controls below it up and back down as each image opens.
HorizontalLayout {
ClipReadout {
title: "Shadows";
figure: root.data.shadows-label;
lit: root.data.shadows-clipped;
}
Rectangle { horizontal-stretch: 1; }
ClipReadout {
title: "Highlights";
figure: root.data.highlights-label;
lit: root.data.highlights-clipped;
}
}
}
}