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
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>
265 lines
10 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|
|
}
|