// TRACES: FR-DSP-7 // The live histogram, and what it says about clipping. // // **One plot, two readings, and a chip row to choose.** The panel draws either // the frame the display is about to show (FR-DSP-7) or the sensor data the // file holds (FR-CULL-3), from the same `HistogramView` and through the same // traces. They are not two presentations of one measurement: the first is // after white balance, the camera matrix and the whole tone chain, the second // is before any of it on an axis of stops below sensor saturation, and // FR-CULL-3 exists precisely because the first "systematically lies about what // is recoverable in the raw". A culling decision needs the second; an export // decision needs the first. Both are true. // // **So the words travel with the numbers.** The axis caption, the two clipping // titles and the empty-state line are fields of the view rather than literals // here, because they differ between the two readings — a raw saturation figure // drawn under a heading saying Highlights would be mislabelled exactly where // the difference matters. // // **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"; import { Segmented } from "controls.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, // The aggregate trace, drawn filled behind the three channels: Rec.709 // luma for the display reading, the brightest channel for the raw one. In // both cases the single curve exposure is read off — which is why it is // named for its role in the plot rather than for either quantity. overall: [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, // What the two clipping readouts are called. Different words for the two // readings — Shadows and Highlights describe a rendering, Black and // Saturated describe a sensor — and they belong to the reading rather than // to the panel for that reason. low-title: string, high-title: string, // What the axis is, or what it currently says: the chip row's hint. For // the raw reading this is the headroom figure, which is the answer // FR-CULL-3 is written for. // // Kept short in Rust, and that is a layout constraint: `FieldRow` draws // the hint as an unwrapped Text, and a Text that does not elide reports // the same *minimum* width as preferred. The develop column no longer // takes the largest of those — it is a mandated `panel-width` — which // makes this constraint sharper rather than softer: an over-long hint no // longer widens the column, it pushes the column's minimum past the width // it has and clips the panel instead. hint: string, // What the plot says when there is nothing to draw. Never "0%" and never // an empty plot — both are claims about a photograph nobody has counted. unavailable: 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; } } // TRACES: NFR-A11Y-3 // 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; } } /// The two readings the histogram can draw. /// /// A global rather than two properties on the panel: the panel is drawn twice /// — see `session.slint` — and both compositions want the same measurement, /// because there is only one photograph being measured. /// /// Only the readings live here. *Which* of them is on the plot does not: that /// is a property of the plot, held by the panel, and two plots of the same /// photograph may honestly be showing different ones. export global Levels { /// The frame the display is about to show (FR-DSP-7). in property data; /// The sensor data the file holds (FR-CULL-3). in property raw-data; } // TRACES: FR-DSP-7 export component HistogramPanel inherits Rectangle { /// Which of the two is on the plot. 0 is the display reading. /// /// **Held here rather than pushed from Rust**, and for the reason /// `chosen-peaking` gives about the focus overlay: this is a way of /// *looking* rather than a property of a photograph. Someone culling three /// thousand frames switches to the raw reading once, and a mode that reset /// with each image would ask them to switch it three thousand times. Both /// views are pushed on every settled frame, so the choice needs no /// callback and no round trip — the raw one costs nothing to keep current, /// because it is computed once per photograph and cached. property mode: 0; /// The reading currently drawn. Everything below reads this and not the /// two above it, so there is exactly one place the choice is made. property shown: root.mode == 1 ? Levels.raw-data : Levels.data; // **This panel does not get a say in how wide the column is.** // // It used to: every panel in the develop column published a // `content-width`, the column took the largest, and that was the whole of // how the column was sized. The trouble is what the column is next to. A // sidebar measured from its contents takes its width out of the // photograph, so an axis label gaining a digit made the picture smaller, // and switching tools swapped one set of panels for another and moved the // image sideways on the screen. // // The column is `panel-width` now, stated once in `style.yaml`. A panel // that wants more than that clips, and the Flickable the column puts // around this is what makes the rest reachable. See the `_develop` note in // `style.yaml` for the trade. 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"; } // **Above the plot rather than below it**, because it says what the // plot *is*: a photographer glancing at a shape has to know which of // the two measurements they are looking at before they read it, not // after. Two chips wide, which fits the narrowest column the // application supports without the row setting the sidebar's width — // see `ChipGrid`. Segmented { label: "Measured on"; hint: root.shown.hint; options: ["Display", "Raw"]; selected: root.mode; columns: 2; picked(i) => { root.mode = i; } } 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 value 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. // // They read as quarters of whichever axis is showing: 64 output // levels for the display reading, four stops for the raw one. That // is why the axis is named in the chip row's hint rather than // labelled here — one set of gridlines cannot carry two scales, // and drawing numbers against them would make one of the two // readings wrong. for i in [1, 2, 3]: Rectangle { x: parent.width * i / 4; width: 1px; background: Theme.rule; opacity: 0.5; } // The aggregate first, so it sits behind: Rec.709 luma for the // display reading, the brightest channel for the raw one, and in // both cases the curve the three channels sit under. A filled area // drawn over a line hides it. Trace { width: 100%; height: 100%; heights: root.shown.overall; 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.shown.red; ink: Theme.plot-red; shade: 0.85; } Trace { width: 100%; height: 100%; heights: root.shown.green; ink: Theme.plot-green; shade: 0.85; } Trace { width: 100%; height: 100%; heights: root.shown.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.shown.shadows-clipped; } Rectangle { x: parent.width - self.width; width: 2px; height: 100%; background: Theme.warn-ink; visible: root.shown.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: root.shown.unavailable; width: 100%; horizontal-alignment: center; y: (parent.height - self.height) / 2; visible: !root.shown.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: root.shown.low-title; figure: root.shown.shadows-label; lit: root.shown.shadows-clipped; } Rectangle { horizontal-stretch: 1; } ClipReadout { title: root.shown.high-title; figure: root.shown.highlights-label; lit: root.shown.highlights-clipped; } } } }