Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
White balance, highlights and shadows, and the mixer took a press, jumped once, and went dead under the finger. Exposure and contrast dragged perfectly — which is what made it read as a slider bug rather than a layout one. The panel nested. A group's head row drew the *whole* group, repeating over `row.group-len` and indexing back into `root.rows`; every other row drew nothing. So the inner repeater's model was read off the head row, and depended on that row's identity. Moving any parameter in the group rewrites that row — its own value changed, or `group-modified` flipped for its neighbours — which re-evaluated the repeater, rebuilt its items, and destroyed the `TouchArea` holding the live gesture. A lone parameter had no inner repeater, so the five single-parameter operations were never affected. Now each row draws only itself, so an update touches one control and nothing structural. The facet heading comes off `starts-facet`, which Rust already marks on the first row of a run, and the group heading is drawn by the row that heads it. It also retires the old hazard of a head row building every control in its group — thirty-six live TouchAreas behind the mixer's twelve visible ones. The same identity hazard reached the rows themselves through `ModelRc`, which compares by identity rather than contents: a fresh empty model per row per call made every row differ from itself, so `sync_rows` rewrote all of them on every event. `points` now shares one empty model as `choices` already did. Both are held by tests, because the failure is invisible in a still — every value is right and the panel looks perfect. Curve rows are excluded: their points model carries live coordinates, is rebuilt by design, and `sync_rows` writes values through the existing model rather than swapping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1888 lines
76 KiB
Rust
1888 lines
76 KiB
Rust
//! The develop session — capabilities in, rendered image out.
|
||
//!
|
||
//! This is the only place the UI touches the pipeline, and it does so through
|
||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||
//! to build, and `set_param` to change one. It never names an operation, and
|
||
//! it knows nothing about shaders.
|
||
//!
|
||
//! Whether a control is a slider or a switch follows from the parameter's
|
||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||
use dr_pipeline::ops::curve;
|
||
use dr_pipeline::{
|
||
CropRect, EditGraph, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit,
|
||
WidgetKind,
|
||
};
|
||
|
||
use crate::labels;
|
||
use crate::ParamRow;
|
||
|
||
/// A loaded image plus its edit state.
|
||
pub struct DevelopSession {
|
||
graph: EditGraph,
|
||
demosaiced: DemosaicedImage,
|
||
adjust: AdjustPass,
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// Demosaic an image and prepare its edit graph.
|
||
///
|
||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||
/// scanned the same way whichever way the body was held, so this is what
|
||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||
/// session and survives a reset.
|
||
pub fn open(
|
||
ctx: &GpuContext,
|
||
raw: &RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||
}
|
||
|
||
/// Prepare an edit graph over an already-processed RGB image.
|
||
///
|
||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||
/// stage to run — but everything after it is identical, which is why this
|
||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||
///
|
||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||
/// exposure has far less latitude here than on sensor data. The controls
|
||
/// are the same controls; the file simply carries less to work with.
|
||
pub fn open_rgb(
|
||
ctx: &GpuContext,
|
||
rgba: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let source =
|
||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, source, orientation))
|
||
}
|
||
|
||
fn with_source(
|
||
ctx: &GpuContext,
|
||
demosaiced: DemosaicedImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Self {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_orientation(orientation);
|
||
Self {
|
||
graph,
|
||
demosaiced,
|
||
adjust: AdjustPass::new(ctx),
|
||
}
|
||
}
|
||
|
||
/// The controls the interface should show.
|
||
///
|
||
/// Built entirely from the capability list. The `kind` string chooses the
|
||
/// widget; nothing switches on a parameter's identity.
|
||
pub fn rows(&self) -> Vec<ParamRow> {
|
||
rows_from(&self.graph.capabilities())
|
||
}
|
||
}
|
||
|
||
// The empty nested models, each a single shared identity.
|
||
//
|
||
// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides
|
||
// which controls to invalidate by comparing each freshly built row against the
|
||
// one on screen. A brand-new empty model per row per call therefore makes every
|
||
// row differ from *itself* on every parameter event, and the panel rewrites all
|
||
// of them.
|
||
//
|
||
// That is not merely wasteful — it breaks dragging. An operation with several
|
||
// parameters renders them through a repeater whose model is read off the
|
||
// group's head row; rewriting that row re-evaluates the repeater, rebuilding
|
||
// its items and destroying the `TouchArea` that holds the gesture. The slider
|
||
// takes the press, jumps once, then goes dead under the finger. Only
|
||
// multi-parameter operations show it, because a lone parameter has no inner
|
||
// repeater to rebuild — which is exactly how it hid: exposure and contrast drag
|
||
// perfectly while temperature and tint do not.
|
||
//
|
||
// Most rows carry neither points nor choices, so the empty case is the common
|
||
// one and it costs nothing to make it a constant.
|
||
|
||
/// The empty points model, shared by every row that is not a curve.
|
||
fn no_points() -> slint::ModelRc<f32> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<f32> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// The empty choices model, shared by every row that is not an enum.
|
||
fn no_choices() -> slint::ModelRc<slint::SharedString> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<slint::SharedString> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
||
///
|
||
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
|
||
/// tone curve is, or hosted on the canvas, as the crop is. Both count as
|
||
/// implemented, and the difference is settled afterwards by
|
||
/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could
|
||
/// disagree about the same kind.
|
||
///
|
||
/// A kind answering `false` here is not an error — the operation's parameters
|
||
/// are ordinary scalars, so it falls back to sliders and stays fully editable
|
||
/// (ARCH §4.3a).
|
||
pub(crate) fn supported(widget: WidgetKind) -> bool {
|
||
match widget {
|
||
// Drawn in the panel.
|
||
WidgetKind::ToneCurve => true,
|
||
// Hosted on the canvas: the overlay is drawn over the photograph and
|
||
// the panel contributes `GeometryPanel`, the affordance that turns it
|
||
// on.
|
||
WidgetKind::CropOverlay => true,
|
||
// Not implemented. Listed rather than caught by a wildcard so the next
|
||
// kind added to the core surfaces here as a compile error.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => false,
|
||
}
|
||
}
|
||
|
||
/// The panel model for a set of capabilities.
|
||
///
|
||
/// Free-standing rather than a method, and that is the point: it needs no GPU,
|
||
/// no decoded image and no session, so the whole descriptor-to-panel path can
|
||
/// be exercised against a hand-built capability list. That is what the
|
||
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||
/// if generating a row requires a device.
|
||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||
let mut rows = Vec::new();
|
||
for (op_index, op) in caps.iter().enumerate() {
|
||
// Where this operation's rows begin. The panel groups by walking
|
||
// back to it, so it has to be taken before any row is pushed.
|
||
let group_head = rows.len();
|
||
// An operation may ask for one widget spanning several
|
||
// parameters. Honouring it is optional — dropping this block
|
||
// renders the same parameters as ordinary sliders, and the edit
|
||
// still works — which is exactly why the hint is a hint.
|
||
if let Some(presentation) = &op.presentation {
|
||
// **The widget registry, and the only one.**
|
||
//
|
||
// `choose` walks the operation's preference list and hands back
|
||
// the first entry this frontend implements (ARCH §4.3a). A kind
|
||
// it does not implement falls through to sliders — the designed
|
||
// behaviour, not a gap, since every parameter is an individually
|
||
// addressable scalar.
|
||
if let Some(widget) = presentation.choose(supported) {
|
||
// **Yielded to the canvas, and this is what replaced naming
|
||
// framing.**
|
||
//
|
||
// This loop used to open with `if op.id == framing::ID { continue }`
|
||
// and a paragraph explaining that a crop is dragged on the
|
||
// photograph rather than typed into four boxes. All of that is
|
||
// true and none of it was this file's to know: it is a fact
|
||
// about the operation, and it now arrives as one. Any stage
|
||
// preferring an on-canvas widget is skipped here on the same
|
||
// terms, with nothing named.
|
||
//
|
||
// Skipped rather than rendered as an affordance row, because
|
||
// the affordance is `GeometryPanel` — a bespoke control for a
|
||
// known stage, which is a thing the interface is entitled to
|
||
// build (ARCH §4.3a draws the line at the *generated* panel
|
||
// naming stages, not at the interface having hand-made
|
||
// widgets).
|
||
if widget.is_on_canvas() {
|
||
continue;
|
||
}
|
||
|
||
// The `match` is exhaustive on purpose. Adding a `WidgetKind`
|
||
// to the core stops this compiling until someone has decided,
|
||
// here, whether the panel draws it.
|
||
let row = match widget {
|
||
WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation),
|
||
// Canvas-hosted kinds returned above; the rest are not
|
||
// implemented and reached sliders via `choose`.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::CropOverlay
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => None,
|
||
};
|
||
if let Some(row) = row {
|
||
rows.push(row);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether anything in this operation has been touched, aggregated
|
||
// before the rows are built so every row of the group can carry
|
||
// the same answer — the panel's heading is one of them and cannot
|
||
// see the others.
|
||
//
|
||
// Derived here rather than asked of the core: a group is a
|
||
// composition this side invented, so whether one is modified is
|
||
// this side's question to answer (ARCH §4.3a).
|
||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||
let group_len = op.params.len() as i32;
|
||
|
||
// The aspect the previous row belonged to, so a run can be told
|
||
// from its continuation. Reset per operation: two operations that
|
||
// happened to facet on the same key are still two groups.
|
||
let mut previous_aspect: Option<&str> = None;
|
||
|
||
for param_index in presentation_order(&op.params) {
|
||
let p = &op.params[param_index];
|
||
// Empty for every kind but `Enum`, which is what the panel
|
||
// keys on to build a segmented control rather than a slider.
|
||
let mut choices: Vec<slint::SharedString> = Vec::new();
|
||
let (kind, min, max, precision, unit) = match &p.kind {
|
||
ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
unit,
|
||
precision,
|
||
..
|
||
} => (
|
||
"scalar",
|
||
*min,
|
||
*max,
|
||
i32::from(*precision),
|
||
unit_suffix(*unit),
|
||
),
|
||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||
// The value is a variant index, so the range is the list's
|
||
// own bounds and the precision is whole numbers. Labels are
|
||
// resolved here, against this crate's catalogue, because
|
||
// the core deals in localisation keys only (NFR-A11Y-1).
|
||
ParamKind::Enum { variants } => {
|
||
choices = variants
|
||
.iter()
|
||
.map(|v| labels::resolve(v.0).into())
|
||
.collect();
|
||
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
|
||
}
|
||
};
|
||
|
||
// A faceted parameter is named by its *subject* — the band —
|
||
// because its aspect is already written above the run it sits
|
||
// in. Unfaceted parameters keep their own label, which is
|
||
// every operation but the mixer.
|
||
let param_label = match &p.facet {
|
||
Some(f) => labels::resolve(f.subject.0),
|
||
None => labels::resolve(p.label.0),
|
||
};
|
||
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
|
||
let starts_facet = aspect.is_some() && aspect != previous_aspect;
|
||
previous_aspect = aspect;
|
||
|
||
rows.push(ParamRow {
|
||
op_index: op_index as i32,
|
||
param_index: param_index as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: param_label.into(),
|
||
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
|
||
starts_facet,
|
||
// -1 rather than an `Option`, which a Slint struct cannot
|
||
// carry: 0° is red, so no value in range can stand for
|
||
// "no swatch".
|
||
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
|
||
group_head: group_head as i32,
|
||
group_len,
|
||
group_modified,
|
||
kind: kind.into(),
|
||
value: p.value,
|
||
default_value: p.default,
|
||
minimum: min,
|
||
maximum: max,
|
||
precision,
|
||
unit: unit.into(),
|
||
// Only curve rows carry points.
|
||
points: no_points(),
|
||
// The shared empty model unless this row really has choices —
|
||
// see `no_choices` for why the identity matters.
|
||
choices: if choices.is_empty() {
|
||
no_choices()
|
||
} else {
|
||
slint::ModelRc::new(slint::VecModel::from(choices))
|
||
},
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// One row standing for a whole curve.
|
||
///
|
||
/// Returns `None` if the operation's parameters do not look like point
|
||
/// coordinates, in which case the caller falls back to sliders rather than
|
||
/// rendering a broken widget.
|
||
fn curve_row(
|
||
op_index: usize,
|
||
group_head: usize,
|
||
op: &OpCapability,
|
||
presentation: &Presentation,
|
||
) -> Option<ParamRow> {
|
||
// Points are x/y pairs, so an odd count means the operation and this
|
||
// code disagree about the layout.
|
||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||
return None;
|
||
}
|
||
|
||
// The widget addresses points by offset from the first, so they must
|
||
// be contiguous in the capability list.
|
||
let base = op
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])?;
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
|
||
log::warn!("{}: curve parameters are not contiguous", op.id);
|
||
return None;
|
||
}
|
||
}
|
||
|
||
let points: Vec<f32> = presentation
|
||
.params
|
||
.iter()
|
||
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
|
||
.map(|p| p.value)
|
||
.collect();
|
||
|
||
Some(ParamRow {
|
||
op_index: op_index as i32,
|
||
// The first point parameter; the widget offsets from here.
|
||
param_index: base as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: String::new().into(),
|
||
// A widget spanning a whole operation is not a row in anyone's
|
||
// grid, so it heads no run and carries no swatch.
|
||
facet_label: String::new().into(),
|
||
starts_facet: false,
|
||
swatch_hue: -1.0,
|
||
group_head: group_head as i32,
|
||
// One widget standing for every parameter of the operation, so
|
||
// the group it heads is itself and nothing else.
|
||
group_len: 1,
|
||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||
kind: "curve".into(),
|
||
value: 0.0,
|
||
default_value: 0.0,
|
||
minimum: 0.0,
|
||
maximum: 1.0,
|
||
precision: 4,
|
||
unit: String::new().into(),
|
||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||
// A curve is not a choice between named alternatives.
|
||
choices: no_choices(),
|
||
})
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// The curve's shape, sampled for drawing.
|
||
///
|
||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||
/// is the line the shader applies. The alternative — reading the curve
|
||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||
/// polyline.
|
||
pub fn curve_samples(&self) -> Vec<f32> {
|
||
const SAMPLES: usize = 96;
|
||
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
let mut found = false;
|
||
|
||
for cap in self.graph.capabilities() {
|
||
if cap.id != curve::ID {
|
||
continue;
|
||
}
|
||
found = true;
|
||
for (i, p) in cap.params.iter().enumerate() {
|
||
let point = i / 2;
|
||
if point >= curve::POINTS {
|
||
break;
|
||
}
|
||
if i % 2 == 0 {
|
||
xs[point] = p.value;
|
||
} else {
|
||
ys[point] = p.value;
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Sorted the same way the operation sorts before handing points to
|
||
// the shader, or a dragged-past point would draw differently from
|
||
// how it renders.
|
||
sort_with_gap(&mut xs);
|
||
|
||
(0..SAMPLES)
|
||
.map(|i| {
|
||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Return every parameter of one operation to its default.
|
||
///
|
||
/// What both a section's reset and a curve's reset do — a curve is one
|
||
/// widget spanning all of its operation's parameters, so "reset this
|
||
/// curve" and "reset this operation" were always the same action. Nothing
|
||
/// here is curve-shaped; it walks whatever parameters the operation
|
||
/// declares.
|
||
pub fn reset_op(&mut self, op_index: i32) {
|
||
let caps = self.graph.capabilities();
|
||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||
return;
|
||
};
|
||
for p in &cap.params {
|
||
self.graph.set_param(cap.id, p.id, p.default);
|
||
}
|
||
}
|
||
|
||
/// Reset a curve, which is to reset its operation.
|
||
///
|
||
/// Kept as its own name because the call site is a curve widget's own
|
||
/// double-click, and reading `reset_curve` there says why it resets ten
|
||
/// parameters at once rather than the one that was clicked.
|
||
pub fn reset_curve(&mut self, op_index: i32) {
|
||
self.reset_op(op_index);
|
||
}
|
||
|
||
/// Apply a change from the interface.
|
||
///
|
||
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
|
||
/// on this side of the boundary.
|
||
pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) {
|
||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||
log::warn!("control at ({op_index}, {param_index}) has no parameter");
|
||
return;
|
||
};
|
||
self.graph.set_param(op, param, value);
|
||
}
|
||
|
||
/// Return one parameter to its default.
|
||
pub fn reset_param(&mut self, op_index: i32, param_index: i32) {
|
||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||
return;
|
||
};
|
||
let default = self
|
||
.graph
|
||
.capabilities()
|
||
.iter()
|
||
.find(|c| c.id == op)
|
||
.and_then(|c| c.params.iter().find(|p| p.id == param))
|
||
.map(|p| p.default)
|
||
.unwrap_or(0.0);
|
||
self.graph.set_param(op, param, default);
|
||
}
|
||
|
||
pub fn reset_all(&mut self) {
|
||
self.graph.reset();
|
||
}
|
||
|
||
fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> {
|
||
// Rows are emitted in capability order, so the flat index is the sum
|
||
// of preceding parameter counts.
|
||
let caps = self.graph.capabilities();
|
||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||
Some((op.id, param.id))
|
||
}
|
||
|
||
/// Render at the requested display size and hand back a Slint image.
|
||
///
|
||
/// Renders at *viewport* resolution rather than sensor resolution, which
|
||
/// is what keeps slider interaction inside the frame budget on a 24 MP
|
||
/// file (FR-DSP-1).
|
||
///
|
||
/// The readback at the end is the temporary bridge documented on
|
||
/// `AdjustPass::read_output`: ARCH §6.1 forbids it, and spike S1 removes
|
||
/// it by importing the texture into Slint directly.
|
||
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
// Fit the render to the viewport while preserving aspect, so the
|
||
// pass does no work on pixels the view will letterbox away.
|
||
//
|
||
// Fitted against the *framed* size, not the sensor's: a crop changes
|
||
// the aspect ratio, and fitting the uncropped shape would letterbox
|
||
// to the wrong box and render the crop squashed.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||
|
||
let shader = self.graph.compose();
|
||
self.adjust
|
||
.render(&self.demosaiced, &shader, w, h)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let (pixels, rw, rh) = self.adjust.read_output().map_err(|e| e.to_string())?;
|
||
let buffer =
|
||
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&pixels, rw, rh);
|
||
Ok(slint::Image::from_rgba8(buffer))
|
||
}
|
||
|
||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||
///
|
||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||
/// area being cropped away would not be on screen and there would be
|
||
/// nothing to drag the handles across. This renders as though the crop
|
||
/// were full, and the interface draws the rect and greys the surround.
|
||
///
|
||
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
|
||
/// handles is two conflicting meanings for one drag, and the handles are
|
||
/// placed against the whole frame in any case.
|
||
///
|
||
/// Returns the image together with the size it was rendered at, since the
|
||
/// overlay has to place its rect against exactly those pixels.
|
||
pub fn render_uncropped(
|
||
&mut self,
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<(slint::Image, u32, u32), String> {
|
||
let saved_crop = self.graph.crop();
|
||
let saved_view = self.graph.framing().view();
|
||
|
||
self.graph.set_crop(CropRect::default());
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
|
||
let result = self.render(width, height);
|
||
|
||
// Restored whatever happened: leaving the graph cropped-to-full on a
|
||
// render error would silently discard the user's crop.
|
||
self.graph.set_crop(saved_crop);
|
||
self.graph.framing_mut().set_view(saved_view);
|
||
|
||
let image = result?;
|
||
let (sw, sh) = self.demosaiced.size();
|
||
// The uncropped frame still turns with the quarter turns, so the
|
||
// overlay's box comes from the framing rather than the sensor.
|
||
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
|
||
Ok((image, rw, rh))
|
||
}
|
||
|
||
/// The displayed size, for sizing the viewport.
|
||
///
|
||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||
/// cropped image against the wrong shape.
|
||
pub fn source_size(&self) -> (u32, u32) {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.output_size(w, h)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9
|
||
/// Render at full resolution and hand back the pixels, for an export.
|
||
///
|
||
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
|
||
/// viewport size, which is what keeps a slider inside the frame budget on
|
||
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
|
||
/// screen-sized file. This renders the framed output size instead, so the
|
||
/// export is the full-quality path FR-EXP-9 requires.
|
||
///
|
||
/// The readback here is `export_pixels`, not the display bridge: a file
|
||
/// is made of bytes on the CPU and there is no path to one that avoids
|
||
/// the transfer. See the note on that method for why the two are separate.
|
||
///
|
||
/// Leaves the pass holding a full-resolution target, so the caller should
|
||
/// expect the next display render to reallocate. Cheaper than keeping a
|
||
/// second pass alive for the exports a session rarely performs.
|
||
pub fn render_for_export(&mut self) -> Result<dr_export::Frame, String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (w, h) = self.graph.output_size(sw, sh);
|
||
|
||
let shader = self.graph.compose();
|
||
self.adjust
|
||
.render(&self.demosaiced, &shader, w, h)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
|
||
dr_export::Frame::new(rw, rh, pixels).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// The sensor's own dimensions, before framing.
|
||
///
|
||
/// What a crop overlay needs: its handles are placed against the full
|
||
/// frame, since that is what the user is selecting *from*.
|
||
pub fn sensor_size(&self) -> (u32, u32) {
|
||
self.demosaiced.size()
|
||
}
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||
/// there is no more detail to show, and smoothing only invents values
|
||
/// between real ones — at which point a photographer inspecting focus or
|
||
/// noise wants to see the pixels, not a blur of them.
|
||
///
|
||
/// Measured against the visible region rather than the zoom factor alone,
|
||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||
/// already magnified at 1×.
|
||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||
|
||
// How many source pixels lie behind the render target: the framed
|
||
// image narrowed to the region the view selects. The target keeps its
|
||
// size while that region shrinks, which is what raises the ratio.
|
||
let view = self.graph.framing().view();
|
||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||
|
||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||
// the same answer, and flipping mode on a rounding error would make the
|
||
// canvas visibly change character mid-scroll.
|
||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||
}
|
||
|
||
/// Set the crop rectangle, in fractions of the source.
|
||
pub fn set_crop(&mut self, rect: CropRect) {
|
||
self.graph.set_crop(rect);
|
||
}
|
||
|
||
pub fn crop(&self) -> CropRect {
|
||
self.graph.crop()
|
||
}
|
||
|
||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||
///
|
||
/// The crop travels with the frame rather than staying where it was on
|
||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||
/// leaving the rect in place while the image turns under it would move the
|
||
/// selection onto a different part of the picture — so the rect is turned
|
||
/// by the same quarter and the composition survives the rotation.
|
||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||
let crop = self.graph.crop();
|
||
if !crop.is_full() {
|
||
self.graph.set_crop(rotate_crop(crop, turns));
|
||
}
|
||
self.graph.rotate_quarters(turns);
|
||
}
|
||
|
||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||
pub fn angle(&self) -> f32 {
|
||
self.graph.framing().angle()
|
||
}
|
||
|
||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||
pub fn quarter_turns(&self) -> u8 {
|
||
self.graph.framing().quarter_turns()
|
||
}
|
||
|
||
pub fn flips(&self) -> (bool, bool) {
|
||
self.graph.framing().flips()
|
||
}
|
||
|
||
/// Mirror horizontally, about the frame's vertical centre line.
|
||
pub fn toggle_flip_h(&mut self) {
|
||
let (h, _) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_H,
|
||
f32::from(u8::from(!h)),
|
||
);
|
||
}
|
||
|
||
pub fn toggle_flip_v(&mut self) {
|
||
let (_, v) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_V,
|
||
f32::from(u8::from(!v)),
|
||
);
|
||
}
|
||
|
||
/// Set the straightening angle, in degrees.
|
||
pub fn set_angle(&mut self, degrees: f32) {
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::ANGLE,
|
||
degrees,
|
||
);
|
||
}
|
||
|
||
/// Whether the framing currently changes the image — what lights the
|
||
/// section's modified dot and enables its reset.
|
||
///
|
||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||
/// the framing active without changing the photograph, and a section that
|
||
/// claimed an edit because the user scrolled would be lying.
|
||
pub fn framing_edits_image(&self) -> bool {
|
||
self.graph.framing().edits_image()
|
||
}
|
||
|
||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||
/// every colour adjustment alone.
|
||
///
|
||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||
/// the framing is an edit, so throwing away where the user was looking
|
||
/// would be an unrelated second effect.
|
||
pub fn reset_framing(&mut self) {
|
||
let view = self.graph.framing().view();
|
||
self.graph.framing_mut().reset();
|
||
self.graph.framing_mut().set_view(view);
|
||
}
|
||
|
||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||
pub fn zoom(&self) -> f32 {
|
||
let v = self.graph.framing().view();
|
||
if v.width <= 0.0 {
|
||
1.0
|
||
} else {
|
||
1.0 / v.width
|
||
}
|
||
}
|
||
|
||
pub fn is_zoomed(&self) -> bool {
|
||
self.graph.framing().is_zoomed()
|
||
}
|
||
|
||
/// Zoom about a point, given in fractions of the *visible* area.
|
||
///
|
||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||
/// magnifying the photograph rather than sliding it around.
|
||
///
|
||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||
const MAX_ZOOM: f32 = 16.0;
|
||
|
||
let view = self.graph.framing().view();
|
||
let current = if view.width > 0.0 {
|
||
1.0 / view.width
|
||
} else {
|
||
1.0
|
||
};
|
||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||
// stopping a fraction short and leaving the image imperceptibly
|
||
// panned.
|
||
let target = if (target - 1.0).abs() < 0.01 {
|
||
1.0
|
||
} else {
|
||
target
|
||
};
|
||
|
||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
|
||
// The point under the cursor, in framed coordinates, must land back
|
||
// under the cursor afterwards.
|
||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||
|
||
self.set_view_clamped(
|
||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||
extent,
|
||
);
|
||
}
|
||
|
||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||
let view = self.graph.framing().view();
|
||
self.set_view_clamped(
|
||
view.x + dx * view.width,
|
||
view.y + dy * view.height,
|
||
view.width,
|
||
);
|
||
}
|
||
|
||
/// Back to fitting the whole frame.
|
||
pub fn reset_zoom(&mut self) {
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
}
|
||
|
||
/// Place a square view of `extent`, keeping it inside the frame.
|
||
///
|
||
/// Clamped rather than allowed to run off the edge: panning past the
|
||
/// boundary would show undefined area beside the photograph, which reads
|
||
/// as a rendering fault rather than as the end of the image.
|
||
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||
let max = 1.0 - extent;
|
||
self.graph.framing_mut().set_view(CropRect {
|
||
x: x.clamp(0.0, max.max(0.0)),
|
||
y: y.clamp(0.0, max.max(0.0)),
|
||
width: extent,
|
||
height: extent,
|
||
});
|
||
}
|
||
|
||
/// The largest centred crop that, at the current straightening angle,
|
||
/// contains no undefined area. What a "straighten and fill" action
|
||
/// applies.
|
||
pub fn max_inscribed_crop(&self) -> CropRect {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.framing().max_inscribed_crop(w, h)
|
||
}
|
||
|
||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||
/// strip can show that slider movement is not recompiling.
|
||
pub fn compiled_pipelines(&self) -> usize {
|
||
self.adjust.cached_pipelines()
|
||
}
|
||
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.graph.is_neutral()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lift this session's edit onto the clipboard.
|
||
///
|
||
/// Captured at full scope — framing included — because the decision about
|
||
/// what travels is made when the preset is *applied*. Copying, then
|
||
/// changing one's mind about the crop, must not mean copying again.
|
||
pub fn copy_settings(&self) -> Preset {
|
||
Preset::capture(&self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Replace this session's edit within `scope`.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||
/// values the sliders are showing, and nothing here pushes them.
|
||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||
preset.apply(&mut self.graph, scope);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8
|
||
/// Load a stored edit, as read from this image's sidecar.
|
||
///
|
||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||
/// so a version that stores nothing opens the photograph at its defaults
|
||
/// rather than leaving the previous image's exposure standing. The file's
|
||
/// orientation survives it, since that was never an edit.
|
||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||
version.apply(&mut self.graph);
|
||
}
|
||
}
|
||
|
||
/// Re-express a crop rect after the frame it is measured against turns.
|
||
///
|
||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||
/// turns have already produced — so turning the frame another quarter leaves
|
||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||
/// onto a different part of the picture, which reads as the rotation having
|
||
/// moved the image rather than the frame.
|
||
///
|
||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||
/// extents; anticlockwise is the same map run the other way. Applied
|
||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||
let mut r = rect;
|
||
for _ in 0..turns.rem_euclid(4) {
|
||
r = CropRect {
|
||
x: 1.0 - r.y - r.height,
|
||
y: r.x,
|
||
width: r.height,
|
||
height: r.width,
|
||
};
|
||
}
|
||
r.normalised()
|
||
}
|
||
|
||
/// Sort ascending and force a minimum separation.
|
||
///
|
||
/// Mirrors what the curve operation does before handing points to the
|
||
/// shader. Duplicated rather than shared because the operation keeps it
|
||
/// private, and the consequence of drift is only a drawn line that lags the
|
||
/// rendered one by a pixel — not a wrong image.
|
||
fn sort_with_gap(xs: &mut [f32]) {
|
||
const MIN_GAP: f32 = 0.001;
|
||
for i in 1..xs.len() {
|
||
let mut j = i;
|
||
while j > 0 && xs[j - 1] > xs[j] {
|
||
xs.swap(j - 1, j);
|
||
j -= 1;
|
||
}
|
||
}
|
||
for i in 1..xs.len() {
|
||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||
xs[i] = xs[i - 1] + MIN_GAP;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
|
||
///
|
||
/// Rendering to the letterboxed size rather than the full viewport avoids
|
||
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
|
||
/// window is a fifth of them.
|
||
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||
if sw == 0 || sh == 0 {
|
||
return (max_w, max_h);
|
||
}
|
||
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
|
||
// Never upscale past the source: there is no detail to recover, and a
|
||
// 1:1 render is cheaper.
|
||
let scale = scale.min(1.0);
|
||
(
|
||
((sw as f32 * scale).round() as u32).max(1),
|
||
((sh as f32 * scale).round() as u32).max(1),
|
||
)
|
||
}
|
||
|
||
/// The order an operation's parameters are shown in.
|
||
///
|
||
/// Declaration order, unless the operation facets them — in which case
|
||
/// parameters sharing an aspect are brought together, so the panel names
|
||
/// each run once instead of repeating "Hue / Saturation / Luminance"
|
||
/// twelve times over. The colour mixer declares band by band, which is the
|
||
/// order the shader wants; a photographer works channel by channel.
|
||
///
|
||
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
|
||
/// says which aspect a parameter belongs to; deciding that an aspect is
|
||
/// worth stacking rows by is the panel's composition to make, exactly as
|
||
/// grouping by operation is. Routing is unaffected — `param_index` stays
|
||
/// the position in the capability list however the rows are stacked.
|
||
///
|
||
/// A stable sort by the aspect's first appearance, so an operation with no
|
||
/// facets comes back untouched, and one that mixes plain parameters with
|
||
/// faceted ones keeps the plain ones first and in order.
|
||
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
|
||
let mut aspects: Vec<&str> = Vec::new();
|
||
let rank: Vec<usize> = params
|
||
.iter()
|
||
.map(|p| match &p.facet {
|
||
None => 0,
|
||
Some(f) => {
|
||
let at = aspects.iter().position(|a| *a == f.aspect.0);
|
||
// First appearance defines the run's place, so the panel's
|
||
// sections come out in the order the operation introduced
|
||
// them rather than alphabetically.
|
||
1 + at.unwrap_or_else(|| {
|
||
aspects.push(f.aspect.0);
|
||
aspects.len() - 1
|
||
})
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let mut order: Vec<usize> = (0..params.len()).collect();
|
||
order.sort_by_key(|i| rank[*i]);
|
||
order
|
||
}
|
||
|
||
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
|
||
/// this function needs no knowledge of which parameter it is formatting.
|
||
fn unit_suffix(unit: Unit) -> &'static str {
|
||
match unit {
|
||
Unit::None => "",
|
||
Unit::Stops => " EV",
|
||
Unit::Kelvin => " K",
|
||
Unit::Percent => "%",
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use dr_pipeline::EditGraph;
|
||
|
||
/// The whole scroll-to-zoom path, end to end, in the order the user drives
|
||
/// it: show the image fitted, *then* turn the wheel.
|
||
///
|
||
/// The lower layers each had zoom tests and each passed while this was
|
||
/// broken, because every one of them set a view before its first render.
|
||
/// That ordering hid the bug — a neutral framing compiles a prologue that
|
||
/// never reads the crop rect, and while zoom was absent from the structure
|
||
/// hash that pipeline stayed cached once zoomed. The session reported the
|
||
/// new zoom, the uniforms carried the new view, and the pixels never moved.
|
||
///
|
||
/// So this asserts on the rendered pixels rather than on `zoom()`: the
|
||
/// symptom was precisely that the state was right and the image was not.
|
||
#[test]
|
||
fn zooming_after_a_fitted_render_changes_the_pixels() {
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// A gradient, so any change in the sampled region moves the pixels.
|
||
let (w, h) = (64u32, 64u32);
|
||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||
}
|
||
}
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let fitted = session.render(64, 64).expect("fitted render");
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||
|
||
let before = fitted.to_rgba8().expect("fitted pixels");
|
||
let after = zoomed.to_rgba8().expect("zoomed pixels");
|
||
let differing = before
|
||
.as_bytes()
|
||
.iter()
|
||
.zip(after.as_bytes().iter())
|
||
.filter(|(a, b)| a != b)
|
||
.count();
|
||
|
||
assert!(
|
||
differing > 0,
|
||
"zooming 4x after a fitted render produced identical pixels — the \
|
||
view reached the session but not the shader"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
|
||
// What decides whether the canvas is filtered. The distinction this
|
||
// guards is the reason the interface cannot answer it from `zoom()`
|
||
// alone: the same 4x on a large source is still showing more source
|
||
// pixels than screen pixels, while on a small one it is already
|
||
// inventing values between them.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||
// every screen pixel still has several source pixels behind it.
|
||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"a downscaled image is not magnified"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"2x on a 4x-downscaled source is still below 1:1"
|
||
);
|
||
session.zoom_about(8.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(200, 200),
|
||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||
);
|
||
|
||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(800, 800),
|
||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||
rounding error"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(800, 800),
|
||
"any zoom past a 1:1 render magnifies"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_capability_becomes_exactly_one_row() {
|
||
// The UI shows what the pipeline offers — no more, and nothing
|
||
// dropped. Asserted against the chain rather than a literal count,
|
||
// so operations can be added without editing this, and so the test
|
||
// actually checks the correspondence rather than restating a number.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
|
||
|
||
assert!(expected > 0, "the chain must expose some parameters");
|
||
// Every (operation, parameter) pair must be reachable as a distinct
|
||
// row index; a collision would route two sliders to one parameter.
|
||
let mut seen = std::collections::HashSet::new();
|
||
for (oi, cap) in caps.iter().enumerate() {
|
||
for (pi, _) in cap.params.iter().enumerate() {
|
||
assert!(seen.insert((oi, pi)), "duplicate row index");
|
||
}
|
||
}
|
||
assert_eq!(seen.len(), expected);
|
||
}
|
||
|
||
#[test]
|
||
fn each_operation_becomes_exactly_one_group() {
|
||
// The panel draws one section per group, and derives the boundary
|
||
// from `group_head` rather than from a flag the core supplies. Two
|
||
// heads for one operation would draw its heading twice; none would
|
||
// swallow the operation into the section above it.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
// A row heads its group exactly when its own index equals its
|
||
// `group_head` — the same test `adjust.slint` makes.
|
||
let mut heads = 0;
|
||
for (i, row) in rows_of(&caps).iter().enumerate() {
|
||
if row.0 == i {
|
||
heads += 1;
|
||
}
|
||
}
|
||
// Every operation but framing, which has its own panel.
|
||
let generated = caps
|
||
.iter()
|
||
.filter(|c| c.id != dr_pipeline::framing::ID)
|
||
.count();
|
||
assert_eq!(heads, generated);
|
||
}
|
||
|
||
#[test]
|
||
fn regenerating_the_rows_leaves_unchanged_ones_equal() {
|
||
// **This is a dragging test wearing a data disguise.**
|
||
//
|
||
// `sync_rows` rewrites exactly the rows that compare unequal, and a
|
||
// rewritten row re-evaluates the repeater that a multi-parameter
|
||
// operation renders its parameters through — which rebuilds the items
|
||
// and destroys the `TouchArea` mid-gesture. So a row that differs from
|
||
// itself between two identical calls is a slider that takes the press,
|
||
// jumps once and then dies under the finger.
|
||
//
|
||
// It is asserted here rather than left to the eye because the failure
|
||
// is invisible in a still: every value is right, the panel looks
|
||
// perfect, and only a live drag on a *grouped* parameter shows it.
|
||
// `ModelRc` compares by identity, so any new model-valued field
|
||
// reintroduces this the moment it is built fresh per call.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let first = rows_from(&caps);
|
||
let second = rows_from(&caps);
|
||
assert_eq!(first.len(), second.len());
|
||
|
||
for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() {
|
||
// A curve row is the one legitimate exception: its `points` model
|
||
// carries live coordinates, so it genuinely is rebuilt each call
|
||
// and `sync_rows` writes the values through the existing model
|
||
// instead of swapping it. Every other row must be stable here, at
|
||
// the source, rather than relying on a caller to repair it.
|
||
if a.kind == "curve" {
|
||
continue;
|
||
}
|
||
assert!(
|
||
a == b,
|
||
"row {i} ({}) differs from itself across two identical builds, \
|
||
so every parameter event would rewrite it and break dragging",
|
||
a.param_label
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_grouped_parameter_survives_a_neighbours_change() {
|
||
// The reported bug, at the level it actually occurred. Moving
|
||
// temperature flips `group_modified` on *both* of white balance's
|
||
// rows — that much is correct and intended. What must not happen is
|
||
// the untouched rows of *other* operations also coming back unequal,
|
||
// because rewriting a group's head row is what rebuilds the repeater
|
||
// holding the live drag.
|
||
let mut graph = EditGraph::default_chain();
|
||
let before = rows_from(&graph.capabilities());
|
||
|
||
// Move the first parameter of the first multi-parameter operation,
|
||
// named by shape rather than by id so this keeps testing the property
|
||
// when the chain changes.
|
||
let caps = graph.capabilities();
|
||
let group = caps
|
||
.iter()
|
||
.find(|c| c.params.len() > 1 && c.presentation.is_none())
|
||
.expect("some operation has several plain parameters");
|
||
let target = &group.params[0];
|
||
graph.set_param(group.id, target.id, target.default + 1.0);
|
||
|
||
let after = rows_from(&graph.capabilities());
|
||
assert_eq!(before.len(), after.len());
|
||
|
||
// Curve rows excluded for the reason given in the test above: their
|
||
// points model is rebuilt by design and repaired in `sync_rows`.
|
||
let changed: Vec<&str> = before
|
||
.iter()
|
||
.zip(after.iter())
|
||
.filter(|(a, b)| a != b && a.kind != "curve")
|
||
.map(|(a, _)| a.param_label.as_str())
|
||
.collect();
|
||
|
||
// Its own group, and nothing beyond it.
|
||
assert_eq!(
|
||
changed.len(),
|
||
group.params.len(),
|
||
"moving one parameter should dirty only its own group's rows, \
|
||
but these came back changed: {changed:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn framing_is_not_generated_as_sliders() {
|
||
// `GeometryPanel` presents crop, rotation, flips and straightening as
|
||
// the gestures they are. If the generic path emitted them too the
|
||
// sidebar would carry both — including four "Crop Left/Top/Width/
|
||
// Height" sliders no one can compose a photograph with.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let framing = caps
|
||
.iter()
|
||
.position(|c| c.id == dr_pipeline::framing::ID)
|
||
.expect("the chain must still expose framing — the panel reads it");
|
||
assert!(!caps[framing].params.is_empty());
|
||
|
||
// Checked against the real generator, and by *routing* rather than by
|
||
// counting: a row carries the capability index it writes back to, so
|
||
// "no row belongs to framing" is the property directly, and it cannot
|
||
// be satisfied accidentally by two miscounts cancelling out.
|
||
let rows = rows_from(&caps);
|
||
assert!(
|
||
rows.iter().all(|r| r.op_index as usize != framing),
|
||
"framing parameters leaked into the generated panel"
|
||
);
|
||
// Every other operation still arrives, so the skip is specific rather
|
||
// than the panel having quietly stopped generating.
|
||
assert!(rows.len() > caps.len() - 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() {
|
||
// The property that replaced `if op.id == framing::ID`. An invented
|
||
// stage preferring an on-canvas widget must be skipped on exactly the
|
||
// same terms — if this needs a name added anywhere to pass, the
|
||
// special case has grown back.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let param = |id: &'static str| ParamCapability {
|
||
id: ParamId(id),
|
||
label: LocalizedKey("param.invented"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
};
|
||
|
||
let on_canvas = OpCapability {
|
||
id: OpId("invented_mask"),
|
||
label: LocalizedKey("op.invented_mask"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
// Prefers a gradient handle; this frontend has none, so it
|
||
// falls back to the next entry, which the canvas does host.
|
||
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: &[ParamId("a"), ParamId("b")],
|
||
}),
|
||
params: vec![param("a"), param("b")],
|
||
};
|
||
|
||
assert!(rows_from(&[on_canvas]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_spans_exactly_its_operations_rows() {
|
||
// `group_len` is how many rows the section reaches forward over. Too
|
||
// few silently drops controls off the bottom of a section; too many
|
||
// reads past the model and renders a neighbouring operation's
|
||
// parameters under the wrong heading.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_of(&caps);
|
||
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let (head, len) = *row;
|
||
assert!(head <= i, "row {i} claims a head after itself");
|
||
assert!(
|
||
head + len <= rows.len(),
|
||
"group at {head} reaches past the model"
|
||
);
|
||
// Every row the group spans must agree it belongs to that group.
|
||
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
|
||
let span = head + offset;
|
||
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_is_modified_when_any_of_its_parameters_is() {
|
||
// The dot on a collapsed section is the only thing saying an edit is
|
||
// hidden inside it, and it is derived here rather than asked of the
|
||
// core (ARCH §4.3a).
|
||
let mut graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
// A fresh chain is at its defaults, so nothing is modified.
|
||
assert!(
|
||
caps.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"a fresh chain must start neutral"
|
||
);
|
||
|
||
// Move one parameter of one operation off its default; only that
|
||
// operation's group may light up.
|
||
let (op_id, param_id, default) = caps
|
||
.iter()
|
||
.find_map(|c| {
|
||
c.params
|
||
.iter()
|
||
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
|
||
.map(|p| (c.id, p.id, p.default))
|
||
})
|
||
.expect("the chain has a scalar parameter");
|
||
graph.set_param(op_id, param_id, default + 1.0);
|
||
|
||
let caps = graph.capabilities();
|
||
let modified: Vec<bool> = caps
|
||
.iter()
|
||
.map(|c| c.params.iter().any(|p| p.value != p.default))
|
||
.collect();
|
||
assert_eq!(
|
||
modified.iter().filter(|m| **m).count(),
|
||
1,
|
||
"one edit must mark exactly one group"
|
||
);
|
||
|
||
// And it goes out again when the value returns.
|
||
graph.set_param(op_id, param_id, default);
|
||
assert!(
|
||
graph
|
||
.capabilities()
|
||
.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"returning a value to its default must clear the group"
|
||
);
|
||
}
|
||
|
||
/// `(group_head, group_len)` per row, flattened as
|
||
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
|
||
/// session.
|
||
///
|
||
/// A widget hint only collapses an operation to one row when it is
|
||
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
|
||
/// here is what keeps the test honest when a hint stops applying.
|
||
/// TRACES: FR-DEV-3c
|
||
/// An operation this file has never heard of, appearing in the panel.
|
||
///
|
||
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
|
||
/// operation added to the registry appears in a generated panel with no
|
||
/// frontend change". Built as a capability rather than a real node so it
|
||
/// costs the pipeline nothing — what is being asserted is the mapping from
|
||
/// descriptor to control, and that mapping does not care whether a shader
|
||
/// exists behind it.
|
||
#[test]
|
||
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
|
||
use dr_pipeline::{LocalizedKey, ParamCapability};
|
||
|
||
let invented = OpCapability {
|
||
id: OpId("invented"),
|
||
label: LocalizedKey("op.invented"),
|
||
active: false,
|
||
presentation: None,
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.invented.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: -100.0,
|
||
max: 100.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::Percent,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 25.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("method"),
|
||
label: LocalizedKey("param.invented.method"),
|
||
kind: ParamKind::Enum {
|
||
variants: &[
|
||
LocalizedKey("param.invented.method.fast"),
|
||
LocalizedKey("param.invented.method.exact"),
|
||
],
|
||
},
|
||
default: 0.0,
|
||
value: 1.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
};
|
||
|
||
let rows = rows_from(&[invented]);
|
||
assert_eq!(rows.len(), 2, "each parameter should become one row");
|
||
|
||
// The scalar becomes a slider carrying its declared range and unit.
|
||
assert_eq!(rows[0].kind, "scalar");
|
||
assert_eq!(rows[0].minimum, -100.0);
|
||
assert_eq!(rows[0].maximum, 100.0);
|
||
assert_eq!(rows[0].value, 25.0);
|
||
|
||
// The enum becomes a choice, with its range spanning the variant
|
||
// indices and the variant names resolved for drawing. Nothing in this
|
||
// file names the operation or either parameter to make that happen.
|
||
assert_eq!(rows[1].kind, "enum");
|
||
assert_eq!(rows[1].minimum, 0.0);
|
||
assert_eq!(rows[1].maximum, 1.0);
|
||
assert_eq!(rows[1].precision, 0);
|
||
assert_eq!(slint::Model::row_count(&rows[1].choices), 2);
|
||
// The value is the selected index, which is what the segmented control
|
||
// reads — an enum needs no separate selection field.
|
||
assert_eq!(rows[1].value, 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() {
|
||
// ARCH §4.3a: falling off the end of the preference list is not an
|
||
// error. An operation asking only for a widget this frontend does not
|
||
// draw must still yield one control per parameter, or declaring a
|
||
// preference would be a way to make an edit unreachable.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let wheel = OpCapability {
|
||
id: OpId("grading"),
|
||
label: LocalizedKey("op.grading"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
widgets: &[WidgetKind::ColourWheel],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: &[ParamId("hue"), ParamId("strength")],
|
||
}),
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("hue"),
|
||
label: LocalizedKey("param.grading.hue"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 360.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.grading.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
};
|
||
|
||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||
let rows = rows_from(&[wheel]);
|
||
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
|
||
assert!(rows.iter().all(|r| r.kind == "scalar"));
|
||
}
|
||
|
||
/// Each generated row's `(group_head, group_len)`.
|
||
///
|
||
/// Taken from the real generator rather than re-derived. This used to be a
|
||
/// hand-written simulation of `rows_from` — it walked the capabilities and
|
||
/// reproduced the grouping rules, including a copy of the framing skip —
|
||
/// which meant the tests below asserted against a second implementation
|
||
/// that had to be kept in step with the first by hand. It was not: giving
|
||
/// framing a presentation changed the real panel and the simulation
|
||
/// disagreed, which is how a passing test suite would have hidden the
|
||
/// change entirely.
|
||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||
rows_from(caps)
|
||
.iter()
|
||
.map(|r| (r.group_head as usize, r.group_len as usize))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||
// The property that makes rotation safe to repeat: a user who turns
|
||
// past the orientation they wanted and keeps going must arrive back at
|
||
// the crop they had, not at a slowly drifting one.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
let mut r = start;
|
||
for _ in 0..4 {
|
||
r = rotate_crop(r, 1);
|
||
}
|
||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||
assert!((r.width - start.width).abs() < 1e-5);
|
||
assert!((r.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||
// A portrait selection on a landscape frame must come out landscape.
|
||
// Were the extents left alone, the rect would keep its old shape while
|
||
// the frame changed to the other one, and the crop would spill off the
|
||
// photograph.
|
||
let r = rotate_crop(
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.25,
|
||
height: 1.0,
|
||
},
|
||
1,
|
||
);
|
||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||
}
|
||
|
||
#[test]
|
||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||
// Whatever the angle and wherever the rect, the result must still be a
|
||
// rect the pipeline can render: outside the unit square it would
|
||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||
for turns in -5..=5 {
|
||
for rect in [
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
},
|
||
CropRect {
|
||
x: 0.7,
|
||
y: 0.8,
|
||
width: 0.3,
|
||
height: 0.2,
|
||
},
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.45,
|
||
width: 0.02,
|
||
height: 0.02,
|
||
},
|
||
] {
|
||
let r = rotate_crop(rect, turns);
|
||
assert!(
|
||
r.x >= 0.0 && r.y >= 0.0,
|
||
"{turns} turns of {rect:?} gave {r:?}"
|
||
);
|
||
assert!(
|
||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||
);
|
||
assert!(
|
||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_quarter_turns_cancel() {
|
||
// The rotate-left and rotate-right buttons must undo one another, or
|
||
// correcting an over-rotation would land somewhere new each time.
|
||
let start = CropRect {
|
||
x: 0.15,
|
||
y: 0.05,
|
||
width: 0.5,
|
||
height: 0.25,
|
||
};
|
||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||
// The common case: rotating an uncropped photograph must not quietly
|
||
// introduce a crop, which would shrink the exported image.
|
||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_without_facets_keeps_its_declared_order() {
|
||
// Every operation but the mixer. Reordering one of these would move
|
||
// Highlights below Shadows for no reason anybody could see in the
|
||
// code, so the stable sort has to be a no-op when nothing is faceted.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
if cap.params.iter().any(|p| p.facet.is_some()) {
|
||
continue;
|
||
}
|
||
let order = presentation_order(&cap.params);
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"{} was reordered",
|
||
cap.id
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn faceted_parameters_are_stacked_one_run_per_aspect() {
|
||
// The panel names a run once and then draws its rows. That only works
|
||
// if a run is *contiguous*: the mixer declares band by band — red hue,
|
||
// red sat, red lum, orange hue — so shown in declaration order every
|
||
// single row would begin a new run, and the panel would draw
|
||
// thirty-six headings over thirty-six sliders.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut seen: Vec<&str> = Vec::new();
|
||
let mut previous: Option<&str> = None;
|
||
for i in presentation_order(&cap.params) {
|
||
let aspect = cap.params[i]
|
||
.facet
|
||
.as_ref()
|
||
.expect("this operation facets every parameter")
|
||
.aspect
|
||
.0;
|
||
if previous != Some(aspect) {
|
||
assert!(
|
||
!seen.contains(&aspect),
|
||
"{aspect} is split into two runs — a heading would be \
|
||
drawn over each half"
|
||
);
|
||
seen.push(aspect);
|
||
previous = Some(aspect);
|
||
}
|
||
}
|
||
assert!(seen.len() > 1, "the fixture must have several aspects");
|
||
}
|
||
|
||
#[test]
|
||
fn reordering_rows_does_not_move_where_a_change_is_routed() {
|
||
// The rows are stacked for reading; `param_index` still addresses the
|
||
// capability list. Were the two confused, dragging a band's Hue would
|
||
// silently write to whichever parameter happened to sit at that
|
||
// position — an edit landing on the wrong control, which reads as the
|
||
// renderer being broken rather than the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut order = presentation_order(&cap.params);
|
||
order.sort_unstable();
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"the order must be a permutation: every parameter reachable from \
|
||
exactly one row, and every row addressing a parameter that exists"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_faceted_parameter_resolves_to_a_band_name() {
|
||
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
|
||
// all thirty-six keys fell through to a derived label that yields the
|
||
// bare channel name — twelve rows reading "Hue" with nothing saying
|
||
// which band. A row identified only by a swatch depends on this
|
||
// resolving, since the name is what a screen reader speaks and what
|
||
// anyone who cannot separate two squares by eye has to go on.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
for p in &cap.params {
|
||
let Some(facet) = &p.facet else { continue };
|
||
let subject = labels::resolve(facet.subject.0);
|
||
let aspect = labels::resolve(facet.aspect.0);
|
||
assert!(!subject.is_empty(), "{} has no subject name", p.id);
|
||
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
|
||
// Not the channel name repeated: that is exactly the failure
|
||
// the catalogue entries were added to fix.
|
||
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unit_suffixes_come_from_the_descriptor() {
|
||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||
assert_eq!(unit_suffix(Unit::None), "");
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_preserves_aspect_ratio() {
|
||
// A 3:2 image in a 16:9 window must letterbox, not stretch.
|
||
let (w, h) = fit(6000, 4000, 1600, 900);
|
||
assert_eq!(h, 900);
|
||
assert!(
|
||
((w as f32 / h as f32) - 1.5).abs() < 0.01,
|
||
"got {w}x{h}, aspect {}",
|
||
w as f32 / h as f32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_never_upscales_past_the_source() {
|
||
// Rendering a 400px image into a 4K window at 4K shades 25x the
|
||
// pixels for no additional detail.
|
||
let (w, h) = fit(400, 300, 3840, 2160);
|
||
assert_eq!((w, h), (400, 300));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_handles_a_degenerate_source() {
|
||
let (w, h) = fit(0, 0, 800, 600);
|
||
assert_eq!((w, h), (800, 600));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_is_bounded_by_the_narrow_axis() {
|
||
// A tall window on a wide image must be limited by width.
|
||
let (w, h) = fit(4000, 1000, 800, 4000);
|
||
assert_eq!(w, 800);
|
||
assert_eq!(h, 200);
|
||
}
|
||
|
||
#[test]
|
||
fn the_curve_collapses_to_a_single_row() {
|
||
// Ten point parameters must appear as one curve control, not ten
|
||
// sliders — otherwise the widget and the sliders both render and the
|
||
// panel shows the same values twice.
|
||
let graph = EditGraph::default_chain();
|
||
let curve_cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("the chain includes a tone curve");
|
||
|
||
assert_eq!(curve_cap.params.len(), curve::POINTS * 2);
|
||
let presentation = curve_cap
|
||
.presentation
|
||
.as_ref()
|
||
.expect("the curve declares a widget");
|
||
// Asked the way the panel asks it: the first preference this frontend
|
||
// implements, not a fixed single kind.
|
||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||
// Every parameter is owned by the widget, so none is left over to be
|
||
// rendered as a stray slider.
|
||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_point_parameters_are_contiguous() {
|
||
// The widget addresses points by offset from the first. Were they
|
||
// interleaved with anything else, dragging a point would write to
|
||
// the wrong parameter.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let base = cap
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])
|
||
.expect("first point is a parameter");
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
assert_eq!(
|
||
cap.params[base + i].id,
|
||
*id,
|
||
"point parameter {i} is out of order"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn curve_samples_start_on_the_diagonal() {
|
||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||
// diagonal — anything else means the widget opens showing a shape
|
||
// the image does not have.
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
for i in 0..curve::POINTS {
|
||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||
xs[i] = t;
|
||
ys[i] = t;
|
||
}
|
||
for i in 0..=20 {
|
||
let x = i as f32 / 20.0;
|
||
let y = curve::evaluate(&xs, &ys, x);
|
||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_enforces_a_minimum_gap() {
|
||
// Two points dragged onto each other would divide by zero in the
|
||
// spline; the drawn curve must survive it exactly as the shader does.
|
||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_orders_reversed_points() {
|
||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn routing_indices_map_back_to_the_right_parameter() {
|
||
// A wrong index would silently move the wrong slider's value, which
|
||
// is exactly the kind of bug that looks like a rendering fault.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
for (oi, op) in caps.iter().enumerate() {
|
||
for (pi, p) in op.params.iter().enumerate() {
|
||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||
assert_eq!(caps[oi].id, op.id);
|
||
}
|
||
}
|
||
}
|
||
}
|