// 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 ink; /// Filled from the baseline rather than drawn as a line. in property filled: false; in property 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 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 title; in property figure; in property 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 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 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; } } } }