Files
DarkRoom/ui/dr-ui/ui/histogram.slint
T
dtourolleandClaude Opus 5 cafa63ca6f
Build and test / Desktop (Linux) (push) Successful in 21m53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 32s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 53m45s
Let the develop column ask how wide it needs to be
The column was 280px, a number chosen for a tablet, with 380px bolted on
later for a desktop. Both were guesses at how much room the widest row
inside needs, and a guess is what cannot work here: the mode strip is one
chip per attribute the *operation set declares*, so the row is generated
and no constant in app.slint can track it.

When the guess came up short the failure was not a tidy clip. The
Flickable inside the column never had its `viewport-width` set, so the
viewport took its content's preferred width, and a viewport wider than
its Flickable is *centred* in it — the same rule the note on the seam's
`x: 0` already records a few lines below. So the column lost half of each
edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten"
read "aighten", Copy sat centred while Paste ran off the far side. It
looked like a rendering fault and it was an alignment one.

So the column asks instead of guessing. Every panel that can appear in it
— image, histogram, geometry, settings transfer, masks, repairs, adjust,
history — now publishes a `content-width`: how wide it has to be before
it starts clipping itself, read off its own layout rather than asserted.
Each declares that as its `min-width` too, and that is what makes the
aggregation automatic: `column` is a layout, so it already reports the
largest minimum among its children, and it does so for the panels that
come and go with the mode as well, which live inside `if`s and cannot be
named from outside. Grep `content-width` in ui/dr-ui/ui to see every
panel with a say in the answer. The mode strip is named explicitly only
because it is pinned outside that layout, so nothing else measures it.

There is no floor left. A floor is one more guess and the panels state
their own minimums now. The only thing still above the measurement is
`panel-max-width`, which is not a size but a policy — a column may not
take the window from the photograph it exists to serve — and it comes
from Rust beside `layout-class` because a width read from `root.width`
inside the layout that `root.width` depends on is a binding loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:23:44 +02:00

265 lines
10 KiB
Plaintext

// 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;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself.
///
/// Every panel in the develop column declares one, and the column takes
/// the largest — that is the whole of how the column is sized. It replaced
/// two guessed constants (280px for a tablet, 380px for a desktop) that
/// could not track a panel gaining a control, and did not.
///
/// Published as `min-width` as well as read by name: the first is what
/// makes the enclosing layout aggregate these automatically, including for
/// the panels that come and go with the mode and so cannot be referenced
/// from outside their `if`.
out property <length> content-width: panel.preferred-width;
min-width: root.content-width;
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;
}
}
}
}