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