Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T
dtourolleandClaude Opus 5 78e3e6b846 Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:37:58 +02:00

259 lines
9.2 KiB
Plaintext

// Generic adjustment controls, generated from pipeline capabilities.
//
// **Nothing here names an operation.** There is no "exposure slider" and no
// "saturation section" — the panel walks a model the core supplies and
// instantiates one control per entry, choosing the control from the
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
// the pipeline makes it appear here with no change to this file
// (FR-DEV-3c).
import { Theme } from "theme.slint";
// One parameter, flattened for Slint's model system.
//
// Flat rather than nested because Slint models do not nest cleanly; the Rust
// side flattens the capability tree into this and carries the indices needed
// to route a change back.
export struct ParamRow {
// Routing back to the core. Opaque to this file.
op-index: int,
param-index: int,
// Resolved display strings. Resolution happens in Rust against the UI's
// catalogue, because the core deals in localisation keys only.
op-label: string,
param-label: string,
// True on the first parameter of each operation, so the panel can draw a
// section heading without knowing what the sections are.
starts-group: bool,
// Which control to build. Mirrors ParamKind.
kind: string, // "scalar" | "bool"
value: float,
default-value: float,
minimum: float,
maximum: float,
precision: int,
unit: string,
}
// A slider with a label, value readout, and double-click reset.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
height: 46px;
VerticalLayout {
spacing: 2px;
HorizontalLayout {
Text {
text: root.data.param-label;
color: root.data.value != root.data.default-value
? Theme.ink : Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
Text {
// Precision comes from the descriptor, so a control in stops
// reads 1.25 while one in whole units reads 25.
text: root.data.precision == 0
? Math.round(root.data.value) + root.data.unit
: (Math.round(root.data.value * 100) / 100) + root.data.unit;
color: root.data.value != root.data.default-value
? Theme.accent : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
// The track. Hand-built rather than using the standard Slint slider
// so the neutral point can be marked — a symmetric control needs to
// show where zero is.
track := Rectangle {
height: Theme.touch-target / 2;
Rectangle {
y: (parent.height - 3px) / 2;
height: 3px;
background: Theme.surface-raised;
border-radius: 1.5px;
}
// The default position, drawn only when it is not at an end.
if root.data.minimum < root.data.default-value
&& root.data.default-value < root.data.maximum: Rectangle {
x: (root.data.default-value - root.data.minimum)
/ (root.data.maximum - root.data.minimum) * parent.width - 1px;
y: (parent.height - 9px) / 2;
width: 2px;
height: 9px;
background: Theme.rule;
}
// Fill from the default to the current value, so the control
// shows the size and direction of the adjustment rather than an
// absolute magnitude.
Rectangle {
property <float> span: root.data.maximum - root.data.minimum;
property <length> default-x:
(root.data.default-value - root.data.minimum) / self.span * parent.width;
property <length> value-x:
(root.data.value - root.data.minimum) / self.span * parent.width;
x: min(self.default-x, self.value-x);
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
y: (parent.height - 3px) / 2;
height: 3px;
background: Theme.accent;
border-radius: 1.5px;
}
handle := Rectangle {
x: (root.data.value - root.data.minimum)
/ (root.data.maximum - root.data.minimum) * parent.width - 6px;
y: (parent.height - 12px) / 2;
width: 12px;
height: 12px;
border-radius: 6px;
background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim;
}
area := TouchArea {
// Explicitly fill the track. A TouchArea with no geometry
// collapses to zero and only reports the events that happen
// to land on it, which shows up as a slider that clicks but
// does not drag.
width: 100%;
height: 100%;
property <float> span: root.data.maximum - root.data.minimum;
function value-at(px: length) -> float {
return clamp(
root.data.minimum + (px / self.width) * self.span,
root.data.minimum,
root.data.maximum);
}
moved => {
// `moved` fires only while pressed, so this is the drag.
root.changed(self.value-at(self.mouse-x));
}
pointer-event(ev) => {
// Jump to the press position, so a click anywhere on the
// track sets the value and a drag continues from there.
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.left) {
root.changed(self.value-at(self.mouse-x));
}
// Right-click resets, alongside double-click.
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
root.reset();
}
}
double-clicked => {
root.reset();
}
}
}
}
}
// The panel: a heading per operation, a control per parameter.
export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows;
in property <bool> enabled: true;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback reset-all();
background: Theme.surface;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
Text {
text: "ADJUST";
color: Theme.accent;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 1.2px;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
reset := TouchArea {
width: 44px;
height: 20px;
clicked => { root.reset-all(); }
Text {
text: "reset";
color: reset.has-hover ? Theme.ink : Theme.ink-faint;
font-size: Theme.text-sm;
horizontal-alignment: right;
vertical-alignment: center;
}
}
}
if !root.enabled: Text {
text: "No image";
color: Theme.ink-faint;
font-size: Theme.text-sm;
}
if root.enabled: Flickable {
viewport-height: content.preferred-height;
content := VerticalLayout {
spacing: 0px;
alignment: start;
for row[i] in root.rows: VerticalLayout {
spacing: 0px;
// Section heading, driven by the flag the core set — this
// file never asks "which operation is this".
if row.starts-group: VerticalLayout {
Rectangle { height: Theme.gap; }
Text {
text: row.op-label;
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 0.8px;
}
Rectangle { height: 2px; }
}
if row.kind == "scalar": ParamSlider {
data: row;
changed(v) => {
root.param-changed(row.op-index, row.param-index, v);
}
reset => {
root.param-reset(row.op-index, row.param-index);
}
}
}
}
}
}
Rectangle {
width: 1px;
background: Theme.rule;
}
}