Say what the application is doing, in one bar and one list

Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:32:09 +02:00
co-authored by Claude Opus 5
parent 70435b712e
commit 65e6a96a65
27 changed files with 3873 additions and 236 deletions
+73
View File
@@ -0,0 +1,73 @@
# WGSL helper functions shared between nodes.
#
# Files here beginning with `_` are not nodes; this one declares the helper
# library every node may draw on by name. `build.rs` generates
# `ops::helpers::<NAME>` from each entry, and validates that a node's
# `helpers:` list names something defined here — a typo is a build error
# naming the key, not a WGSL compile failure in generated source.
#
# **Single source of truth.** The composer deduplicates helpers by *name*, so
# two definitions of one name would silently emit whichever came first and two
# nodes would compute, say, luminance differently depending on graph order.
# That is a genuinely hard bug to see, which is why a helper is defined
# exactly once, here, and referenced everywhere else.
doc: |
WGSL helper functions shared between operations.
Generated from `ops/_helpers.yaml`. A node names the helpers it needs and
the composer emits each one once, however many nodes asked for it.
helpers:
luminance:
doc: |
Rec. 709 luminance, the weighting that matches sRGB primaries.
Applied to camera-space values it is an approximation — the true weights
depend on the camera matrix — but using it here keeps the tonal operations
working on sensor-native data, where highlight headroom still exists.
wgsl: |
fn luminance(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
}
tone_position:
doc: |
Map linear luminance onto a perceptual 0..1 position.
Tonal controls must feel evenly spaced to the eye, and linear light is
not: middle grey sits at 0.18, so a linear weight would call almost
everything a shadow. The cube root approximates lightness cheaply and
behaves well near zero, where a log would diverge.
wgsl: |
fn tone_position(luma: f32) -> f32 {
return clamp(pow(max(luma, 0.0), 1.0 / 3.0), 0.0, 1.0);
}
apply_tone_gain:
doc: |
Scale a colour by a gain while preserving its hue.
Multiplying the three channels equally keeps chromaticity fixed, so
lifting shadows does not desaturate them the way an additive lift would.
wgsl: |
fn apply_tone_gain(c: vec3<f32>, gain: f32) -> vec3<f32> {
return c * gain;
}
colour_saturation:
doc: |
How far a colour sits from grey, in 0..1.
The max-minus-min definition (HSV chroma) rather than a standard
deviation: it matches what the eye reads as 'colourfulness' and it is what
makes vibrance's roll-off land where users expect.
wgsl: |
fn colour_saturation(c: vec3<f32>) -> f32 {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
if (hi <= 0.0) {
return 0.0;
}
return (hi - lo) / hi;
}
+86
View File
@@ -0,0 +1,86 @@
id: blacks_whites
label: op.blacks_whites
order: 50
doc: |
Blacks and whites — the endpoints, where the image clips.
Narrow where [`highlights_shadows`](highlights_shadows.yaml) is broad:
whites act only in the top quarter, blacks only in the bottom quarter. That
difference in reach is the whole distinction between the two pairs.
placement: |
After the broad recovery controls: the endpoints are placed once the tonal
range they bound has been settled.
params:
blacks:
label: param.blacks
kind: amount
whites:
label: param.whites
kind: amount
uniforms:
white_amount: whites / 100
black_amount:
value: blacks / 100 * 0.02
doc: |
A small linear offset. Scene-referred black sits near zero, so the
useful range here is far smaller than a stop — 0.02 is already a
visible lift on a dark frame.
helpers: [luminance, tone_position, apply_tone_gain]
wgsl: |
let luma = luminance(c);
let pos = tone_position(luma);
// Narrow weights concentrated at each end — this is what separates these
// controls from highlights/shadows, which are broad. Whites act only in the
// top quarter, blacks only in the bottom quarter.
let white_w = smoothstep(0.75, 1.0, pos);
let black_w = 1.0 - smoothstep(0.0, 0.25, pos);
// Whites scale the top end multiplicatively, moving the clipping point.
let white_gain = exp2(white_amount * white_w);
c = apply_tone_gain(c, white_gain);
// Blacks shift the floor. This one is deliberately *additive*: the point of
// a blacks control is to set where the image reaches zero, and a multiply
// can never bring a non-zero value to zero nor lift a true black off it.
c = c + vec3<f32>(black_amount * black_w);
// The subtractive direction can push below zero, which is not light.
c = max(c, vec3<f32>(0.0));
tests:
- name: it_starts_neutral
expect_active: false
- name: one_parameter_is_enough_to_activate
set: { whites: 20 }
expect_active: true
- name: the_blacks_offset_stays_small
why: |
Scene-referred black is near zero; a full-stop control here would be
unusable, moving the image to grey at a fraction of its travel.
set: { blacks: 100 }
expect_range: { black_amount: [0.0, 0.05] }
- name: blacks_are_additive_not_multiplicative
why: |
A multiply can never bring a non-zero value to zero nor lift a true
black off it, which is precisely what this control is for.
expect_wgsl: ["c = c + vec3<f32>(black_amount * black_w);"]
- name: the_result_cannot_go_below_zero
why: A negative radiance is not light, and it poisons everything downstream.
expect_wgsl: ["c = max(c, vec3<f32>(0.0));"]
- name: these_endpoints_are_narrower_than_the_recovery_controls
why: |
The one property distinguishing this node from highlights_shadows. If
these weights widened to match, the two would be the same control twice.
expect_wgsl: ["smoothstep(0.75, 1.0, pos)", "smoothstep(0.0, 0.25, pos)"]
+68
View File
@@ -0,0 +1,68 @@
id: brilliance
label: op.brilliance
order: 70
doc: |
Brilliance — shadows up and highlights down at once.
Apple's control, and a genuinely different idea from contrast: it applies
the opposite correction at each end of the range while leaving mid-tones
alone. The result reads as "more light in the scene" rather than "less
contrast", because local relationships survive where a plain contrast
reduction flattens them.
It overlaps with highlights/shadows deliberately — one control doing both
in a fixed relationship is easier to reach for than two controls needing
to be balanced against each other.
placement: |
After the tone curve has had the final word on tone, and before the colour
controls, which should act on the tones the photographer has settled.
params:
brilliance:
label: param.brilliance
kind: amount
uniforms:
amount:
value: brilliance / 100 * 0.5
doc: |
Half a stop at each end at full travel — the two ends move apart by a
stop in total, which is a strong but not destructive flattening.
helpers: [luminance, tone_position, apply_tone_gain]
wgsl: |
let luma = luminance(c);
let pos = tone_position(luma);
// Opposite corrections at the two ends: shadows up, highlights down, both
// tapering to nothing at the mid-point. This is what separates brilliance
// from a contrast control — mid-tones keep their local relationships, so
// the image gains apparent light rather than losing structure.
let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount;
let pull = smoothstep(0.5, 1.0, pos) * amount;
// A mild saturation compensation. Flattening the tonal range washes colour
// out; without this, brilliance looks faded at useful settings.
let gain = exp2(lift - pull);
c = c * gain;
let luma_after = luminance(c);
c = mix(vec3<f32>(luma_after), c, 1.0 + max(amount, 0.0) * 0.15);
c = max(c, vec3<f32>(0.0));
tests:
- name: it_starts_neutral
expect_active: false
- name: full_travel_is_half_a_stop_at_each_end
set: { brilliance: 100 }
expect: { amount: 0.5 }
- name: the_two_ends_move_in_opposite_directions
why: |
The property that makes this brilliance rather than a contrast slider.
Both terms scaling the same way would flatten without lifting.
expect_wgsl: ["let gain = exp2(lift - pull);"]
+14
View File
@@ -0,0 +1,14 @@
id: colour_mixer
order: 100
rust: ColourMixer
why_rust: |
Thirty-six parameters — twelve hue bands times hue, saturation and
luminance — generated as a grid, each carrying a `Facet` naming its band and
the band's centre on the hue wheel. Spelling that out as thirty-six YAML
entries would be a worse description than the loop that produces it, and the
band centres are computed rather than listed.
placement: |
Last. It is the finishing control, and it should act on the tones the user
has already settled.
+110
View File
@@ -0,0 +1,110 @@
id: contrast
label: op.contrast
order: 30
doc: |
Contrast — an S-curve about a fixed mid-point.
Pushes tones away from middle grey (positive) or toward it (negative),
pivoting where the eye reads "neither light nor dark". In linear light
that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
stop and a half above middle grey, and pivoting there would darken almost
every photograph.
The curve is applied in a perceptual domain rather than directly to linear
values. Applied linearly, an S-curve crushes shadows far harder than it
lifts highlights, because linear light devotes most of its range to the
brightest stop.
placement: |
After the capture corrections, before the region controls: it is the broad
tonal statement those controls then refine.
params:
contrast:
label: param.contrast
kind: amount
uniforms:
amount:
value: contrast / 100
doc: The shader's curve expects -1..1; the descriptor speaks -100..100.
helpers: [luminance, apply_tone_gain]
define:
contrast_curve: |
// A symmetric S-curve on a 0..1 perceptual position.
//
// `amount` above zero steepens, below zero flattens. The smoothstep form is
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
}
wgsl: |
let luma = luminance(c);
if (luma > 0.0001) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
// curve operates on luma/(2*0.18) so that middle grey lands at the
// curve's own 0.5 pivot.
let pos = clamp(luma / 0.36, 0.0, 1.0);
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
c = max(c, vec3<f32>(0.0));
tests:
- name: neutral_does_nothing
expect: { amount: 0.0 }
expect_active: false
- name: the_amount_is_normalised_to_unit_range
set: { contrast: 100 }
expect: { amount: 1.0 }
- name: the_negative_direction_normalises_too
set: { contrast: -100 }
expect: { amount: -1.0 }
- name: contrast_works_on_luminance_not_per_channel
why: |
Curving each channel separately shifts hue; the ratio form is what keeps
a blue sky blue as contrast rises.
expect_wgsl: ["luminance(c)", "apply_tone_gain"]
- name: the_pivot_is_middle_grey_not_half
why: |
Pivoting at 0.5 in linear light would darken nearly every image:
scene-referred 0.5 is well above what the eye calls mid-tone.
expect_wgsl: ["0.36"]
- name: a_division_by_luminance_is_guarded
why: |
A black pixel has zero luminance; dividing by it would produce NaN and
propagate through everything downstream.
expect_wgsl: ["luma > 0.0001"]
- name: the_curve_cannot_invert
why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate
settings, which inverts tones. smoothstep cannot.
expect_helper_wgsl: { contrast_curve: ["3.0 - 2.0 * clamped"] }
+57
View File
@@ -0,0 +1,57 @@
# TRACES: FR-DEV-3a | FR-DEV-3c
id: exposure
label: op.exposure
order: 20
doc: |
Exposure — a linear gain, expressed in stops.
The simplest operation in the pipeline and the one that most justifies
working in linear light: a stop is a doubling, so exposure is a single
multiply. Applied to gamma-encoded data it would be neither a doubling nor
reversible, which is why this stage sits where it does (ARCH §5.2).
placement: |
A correction to how the scene was captured, so it precedes every operation
that interprets tone.
params:
exposure:
label: param.exposure
kind: stops
min: -5
max: 5
doc: |
±5 stops. Wider than most edits need, but recovering a badly
underexposed frame is a real use and raw data often supports it.
uniforms:
gain:
value: exp2(exposure)
doc: The linear gain for the current setting.
wgsl: |
c = c * gain;
tests:
- name: one_stop_is_a_doubling
why: |
The definition of a stop. If this is wrong, every exposure adjustment is
subtly off and no test of "looks right" would catch it.
set: { exposure: 1 }
expect: { gain: 2.0 }
- name: one_stop_down_is_a_halving
set: { exposure: -1 }
expect: { gain: 0.5 }
- name: stops_compose_additively
why: +2 stops must equal +1 applied twice.
set: { exposure: 2 }
expect: { gain: 4.0 }
- name: neutral_is_unity_gain
why: |
An unedited image must be the image, not an interpretation of it.
expect: { gain: 1.0 }
expect_active: false
@@ -0,0 +1,81 @@
id: highlights_shadows
label: op.highlights_shadows
order: 40
doc: |
Highlights and shadows — broad, overlapping recovery at both ends.
Weights are built from smoothstep rather than a hard threshold: a sharp
boundary produces visible banding on a gradient — a sky is the worst case,
and it is also the most common subject for these controls.
Broad where [`blacks_whites`](blacks_whites.yaml) is narrow. These are the
controls used to tame a contrasty scene; those are the ones used to place
the endpoints.
placement: |
After the broad tonal statement contrast makes, so these refine it.
params:
highlights:
label: param.highlights
kind: amount
doc: |
Negative recovers highlights, the overwhelmingly common direction,
matching the convention every other developer uses.
shadows:
label: param.shadows
kind: amount
uniforms:
hi_amount:
value: highlights / 100
doc: |
A full stop at the extreme; enough to recover a bright sky without
inverting the tonal relationship.
lo_amount: shadows / 100
helpers: [luminance, tone_position, apply_tone_gain]
wgsl: |
let luma = luminance(c);
let pos = tone_position(luma);
// Broad, overlapping weights. Highlights ramp in over the upper half,
// shadows out over the lower half, so a mid-tone is barely touched by
// either and the two controls blend rather than fighting at the join.
let hi_w = smoothstep(0.5, 1.0, pos);
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
// Each control contributes up to a stop of gain at full deflection.
// exp2 keeps the effect symmetric: -100 and +100 are inverse.
let hi_gain = exp2(hi_amount * hi_w);
let lo_gain = exp2(lo_amount * lo_w);
c = apply_tone_gain(c, hi_gain * lo_gain);
tests:
- name: it_starts_neutral
expect_active: false
- name: one_parameter_is_enough_to_activate
set: { highlights: -50 }
expect_active: true
- name: highlight_recovery_is_the_negative_direction
why: The convention users expect — dragging left recovers. Full travel is one stop.
set: { highlights: -100 }
expect: { hi_amount: -1.0 }
- name: tonal_amounts_are_symmetric
why: |
exp2 of equal and opposite exponents multiplies to 1, so +100 and -100
have to be exact negations for the two directions to cancel.
set: { shadows: 100 }
expect: { lo_amount: 1.0 }
- name: the_weights_are_smooth_not_thresholded
why: |
A hard boundary bands visibly on a gradient, and a sky is both the worst
case and the most common subject for this control.
expect_wgsl: ["smoothstep(0.5, 1.0, pos)", "smoothstep(0.0, 0.5, pos)"]
+60
View File
@@ -0,0 +1,60 @@
id: saturation
label: op.saturation
order: 90
doc: |
Saturation — every colour's distance from grey, scaled equally.
The blunt instrument beside [`vibrance`](vibrance.yaml). Both are offered
because they fail differently: this one is predictable and even, which is
what a landscape wants, and ruinous on faces, which is what vibrance is for.
placement: |
Last of the per-colour controls, so it has the final word if both it and
vibrance are in play.
params:
saturation:
label: param.saturation
kind: amount
uniforms:
factor:
value: max(1 + saturation / 100, 0)
doc: |
-100 reaches exactly monochrome; +100 doubles the distance from grey.
The floor at zero matters: a negative factor would push a colour past
grey into its complement, inverting hues.
helpers: [luminance, tone_position, apply_tone_gain]
wgsl: |
// Interpolate away from the luminance-preserving grey. A factor of 0 is
// monochrome, 1 is unchanged, above 1 is more saturated.
let luma = luminance(c);
c = mix(vec3<f32>(luma), c, factor);
c = max(c, vec3<f32>(0.0));
tests:
- name: it_starts_unchanged
expect: { factor: 1.0 }
expect_active: false
- name: full_negative_saturation_reaches_monochrome
why: |
The property that makes -100 meaningful: it must land exactly on grey,
not merely near it.
set: { saturation: -100 }
expect: { factor: 0.0 }
- name: positive_saturation_increases_the_factor
set: { saturation: 100 }
expect: { factor: 2.0 }
- name: the_factor_never_goes_negative
why: |
A negative factor pushes a colour past grey into its complement, which
inverts hues rather than desaturating them. The clamp is what makes the
bottom of the slider's travel monochrome instead of a solarised image.
set: { saturation: -100 }
expect_range: { factor: [0.0, 2.0] }
+23
View File
@@ -0,0 +1,23 @@
# A hand-written node. `rust:` names the type in `crate::ops` that implements
# `Operation`; everything else about it — its descriptor, its parameters, its
# WGSL — comes from that type rather than from this file.
#
# It appears here anyway so that `ops/` lists the whole pipeline in order.
# A chain half-declared here and half-ordered in Rust would be worse than
# either alone: the order is the one thing a reader comes to this directory
# to learn.
id: tone_curve
order: 60
rust: ToneCurve
why_rust: |
Five control points presented as one curve widget, with an interpolator and
a monotonicity guarantee behind it. Its neutral is a *relationship* between
parameters rather than a set of values — the identity diagonal — which is
not something the declarative `active:` rule can express, and its
`presentation()` spans parameters rather than describing one.
placement: |
After the fixed-weight region controls, so the curve is the final word on
tone: a photographer reaches for it to fix what those controls could not
place exactly.
+65
View File
@@ -0,0 +1,65 @@
id: vibrance
label: op.vibrance
order: 80
doc: |
Vibrance — saturation weighted toward the muted colours.
Where [`saturation`](saturation.yaml) scales every colour's distance from
grey equally, vibrance scales it *more for muted colours than for already
saturated ones*, and protects skin tones. The difference matters: pushing
saturation on a portrait turns faces orange long before the background
improves, which is precisely the problem vibrance was invented to solve.
placement: |
Before saturation, so the broad control has the last word if both are used.
params:
vibrance:
label: param.vibrance
kind: amount
uniforms:
amount: vibrance / 100
helpers: [luminance, tone_position, colour_saturation]
wgsl: |
let luma = luminance(c);
let sat = colour_saturation(c);
// The vibrance curve: full effect on grey, tapering to nothing on colours
// that are already saturated. Squaring the falloff keeps the mid-range
// responsive while still protecting the extremes.
let falloff = (1.0 - sat) * (1.0 - sat);
// Skin protection. Skin sits in a narrow band of hue where red leads green
// leads blue; pushing it is what makes vibrance look wrong on portraits.
// Detected by channel ordering rather than a hue angle, which costs a
// conversion and buys nothing here.
let is_skin = f32(c.r > c.g && c.g > c.b);
let skin_guard = 1.0 - is_skin * 0.5;
let strength = amount * falloff * skin_guard;
c = mix(vec3<f32>(luma), c, 1.0 + strength);
c = max(c, vec3<f32>(0.0));
tests:
- name: it_starts_neutral
expect_active: false
- name: the_amount_is_normalised_to_unit_range
set: { vibrance: 100 }
expect: { amount: 1.0 }
- name: muted_colours_get_more_than_saturated_ones
why: |
The one property that distinguishes vibrance from saturation. Without
the falloff term this node would be a duplicate of its neighbour.
expect_wgsl: ["let falloff = (1.0 - sat) * (1.0 - sat);"]
- name: skin_tones_are_protected
why: |
The reason vibrance exists. A portrait pushed on plain saturation goes
orange long before the background improves.
expect_wgsl: ["let skin_guard = 1.0 - is_skin * 0.5;"]
+86
View File
@@ -0,0 +1,86 @@
id: white_balance
label: op.white_balance
order: 10
doc: |
White balance — temperature and tint, relative to as-shot.
Expressed as an offset from what the camera chose rather than an absolute
kelvin value. Neutral means "as shot", so the control starts where the
image already is and a reset returns there. An absolute scale would make
the neutral position depend on the file, which is exactly the confusion
Lightroom's temperature slider creates on non-raw files.
The as-shot multipliers themselves are applied by the composer's preamble
rather than here — every image has them even when this operation is
neutral, so they cannot live in a fragment that vanishes at neutral.
placement: |
First. It is a correction to how the scene was captured, and every tonal
operation after it should act on a correctly balanced image.
params:
temperature:
label: param.temperature
kind: amount
doc: |
Warmer is positive, matching every other raw developer: dragging right
makes the image warmer, even though that means *lowering* the colour
temperature being corrected for.
tint:
label: param.tint
kind: amount
# Temperature trades red against blue; tint trades green against magenta.
# Both are scaled so the full range is a strong but not destructive
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
# which covers ordinary illuminant error without letting the slider blow a
# channel on its own.
uniforms:
mul_r: exp2(temperature / 100 * 0.5)
mul_g:
value: exp2(-(tint / 100 * 0.5))
doc: |
Green is held at unity by temperature, so the control does not double as
an exposure slider — green carries most of the luminance.
mul_b:
value: exp2(-(temperature / 100 * 0.5))
doc: Blue moves opposite red, so a neutral grey stays grey as the control moves.
wgsl: |
c = c * vec3<f32>(mul_r, mul_g, mul_b);
tests:
- name: neutral_is_as_shot
expect: { mul_r: 1.0, mul_g: 1.0, mul_b: 1.0 }
expect_active: false
- name: warming_raises_red_and_lowers_blue
set: { temperature: 100 }
expect: { mul_r: 1.4142135, mul_b: 0.70710677 }
- name: temperature_leaves_green_alone
why: |
Otherwise the control doubles as an exposure slider, because green
carries most of the luminance.
set: { temperature: 100 }
expect: { mul_g: 1.0 }
- name: tint_moves_green_against_magenta
why: Positive tint reduces green, and must not touch red or blue.
set: { tint: 100 }
expect: { mul_g: 0.70710677, mul_r: 1.0, mul_b: 1.0 }
- name: cooling_is_the_inverse_of_warming
why: |
Warming by n then cooling by n must return to neutral, so the two
directions have to be exact reciprocals rather than merely similar.
set: { temperature: -100 }
expect: { mul_r: 0.70710677, mul_b: 1.4142135 }
- name: the_extremes_stay_within_half_a_stop
why: |
A white balance control that can blow a channel by itself is a trap;
correction belongs in a range where highlights survive.
set: { temperature: 100, tint: 100 }
expect_range: { mul_r: [0.70, 1.42], mul_g: [0.70, 1.42], mul_b: [0.70, 1.42] }