develop.rs had grown to 9,327 lines covering everything the develop session does: opening a photograph, the parameter-row and curve-widget panel model, mask viewing and editing, mask creation and the rasteriser that turns a mask stack into GPU arrays, spot repairs, scene segmentation, framing and zoom, white-balance sampling, rendering and film choice, and the undo/snapshot history. docs/dev/code-health.md CH-1 names dr-ui's lack of a view layer as the reason every feature kept landing in a handful of files; this is the first of the two pure splits it recommends as easy, no-behaviour-change wins independent of that larger rework. The boundaries follow the file's own sections (several were already marked off with comment headers) and the seams a full read turned up underneath them -- mask storage/rasterisation turned out to be a distinct concern from mask viewing and editing, and rows/tabs/curves from each other, so those split further than the headers alone suggested. Each module stays under about 1,500 lines. Struct fields and the handful of helper methods now called from a sibling module became `pub(super)`, which is strictly narrower than the whole-crate reachability a single file gave them; nothing gained visibility outside `develop`. Tests moved with the code they test, including the few cases where a helper one file's tests needed was itself only defined in another's -- those became shared fixtures in `mod.rs` alongside the `headless`/`read_back`/`grey_session` helpers that already worked that way. `mod.rs` re-exports every item `develop::` callers outside this module used before, so lib.rs, masks_ui.rs and the rest needed no changes.
1237 lines
53 KiB
Rust
1237 lines
53 KiB
Rust
//! Turning `EditGraph` capabilities into the flat `ParamRow` list the panel
|
|
//! draws, and the handful of session methods (`set_param`, `reset_*`) that
|
|
//! write back through the same row indices.
|
|
use dr_pipeline::{Edit, OpCapability, OpId, ParamId, ParamKind, Unit, WidgetKind};
|
|
#[cfg(test)]
|
|
use dr_pipeline::{EditGraph, Presentation};
|
|
|
|
use crate::labels;
|
|
use crate::ParamRow;
|
|
|
|
use super::curves::curve_row;
|
|
use super::session::DevelopSession;
|
|
|
|
// 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.
|
|
pub(super) 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.
|
|
pub(super) 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)
|
|
}
|
|
|
|
/// The choices model for one enum parameter, built once per variant list.
|
|
///
|
|
/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares
|
|
/// by *identity*, so building a fresh one each call makes the row differ from
|
|
/// itself on every parameter event. `sync_rows` would then replace the row —
|
|
/// destroying the elements built from it, including whichever `TouchArea` is
|
|
/// holding the current gesture — and the enum's own control would fight every
|
|
/// slider drag elsewhere in the panel.
|
|
///
|
|
/// Curve rows solve the same problem the other way, by writing new values
|
|
/// through the existing model. That is not available here: a variant list is
|
|
/// fixed at compile time, so the model never needs updating and can simply be
|
|
/// the same one every time.
|
|
///
|
|
/// Keyed on the labels rather than the slice's address, because they are
|
|
/// resolved through the UI's catalogue and two operations offering the same
|
|
/// choices should share one model.
|
|
fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc<slint::SharedString> {
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
|
|
thread_local! {
|
|
static CACHE: RefCell<HashMap<String, slint::ModelRc<slint::SharedString>>> =
|
|
RefCell::new(HashMap::new());
|
|
}
|
|
let key = labels.join("\u{1f}");
|
|
CACHE.with(|cache| {
|
|
cache
|
|
.borrow_mut()
|
|
.entry(key)
|
|
.or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec())))
|
|
.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 `ComposePanel`, the affordance that turns it
|
|
// on.
|
|
WidgetKind::CropOverlay => true,
|
|
// TRACES: FR-DEV-3
|
|
// Hosted on the canvas too — a click on the photograph — with the
|
|
// affordance that arms it in the group's own heading. See
|
|
// `samples_the_canvas` for why this one leaves its sliders standing
|
|
// where the crop takes them away.
|
|
WidgetKind::WhitePoint => 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 => false,
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-UI-7
|
|
/// Whether an on-canvas widget *reads* the photograph rather than replacing
|
|
/// its parameters with handles.
|
|
///
|
|
/// **This is the distinction that stopped the picker eating its own sliders.**
|
|
/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the
|
|
/// panel had been treating that as also meaning "and so the panel draws
|
|
/// nothing for it". For a crop that is right: four edge fractions and an angle
|
|
/// are not controls anybody drags in a list, and the whole reason the crop is
|
|
/// on the photograph is that they are unusable anywhere else.
|
|
///
|
|
/// An eyedropper is the other thing. It *writes* temperature and tint — they
|
|
/// remain exactly the controls a photographer reaches for afterwards, because
|
|
/// a sampled neutral is a starting point and warming a portrait past it is the
|
|
/// next move, not a mistake. Taking the sliders away to make room for the
|
|
/// picker would be trading a control for a control.
|
|
///
|
|
/// So the panel draws the group as usual and puts the affordance that arms the
|
|
/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one
|
|
/// word doing a lot of work, and this is the word.
|
|
pub(super) fn samples_the_canvas(widget: WidgetKind) -> bool {
|
|
match widget {
|
|
WidgetKind::WhitePoint => true,
|
|
// Dragged rather than sampled: the parameters *are* the handles.
|
|
WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
|
|
// Not on the canvas at all, so nothing asks. Listed rather than
|
|
// wildcarded for the reason `supported` lists its own.
|
|
WidgetKind::ToneCurve | WidgetKind::ColourWheel => 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.
|
|
///
|
|
/// `#[cfg(test)]` since the panel began passing the selected curve down: the
|
|
/// session always has one to pass, and a wrapper that quietly picked the first
|
|
/// would be a second answer to a question the session already answers.
|
|
#[cfg(test)]
|
|
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
|
rows_filtered(caps, |_| true, 0)
|
|
}
|
|
|
|
/// The panel model for the capabilities `keep` accepts.
|
|
///
|
|
/// **`op_index` counts over every capability, not over the kept ones.** It is
|
|
/// how a row routes back to the core, so filtering must not renumber it — a
|
|
/// row that survived a filter has to still name the operation it came from.
|
|
/// `group_head` is the opposite: a position within the *emitted* rows, because
|
|
/// the panel walks back to it through the model it was given.
|
|
///
|
|
/// Getting that backwards is how a slider ends up driving a different
|
|
/// operation, which is the kind of fault that looks like a rendering bug.
|
|
///
|
|
/// `curve_channel` is which subject a multi-subject widget is showing — the
|
|
/// tone curve's four curves are one plot with a selector over it. It is passed
|
|
/// in rather than read from anywhere because this function is deliberately
|
|
/// free-standing: the descriptor-to-panel path has to be exercisable against a
|
|
/// hand-built capability list with no session behind it.
|
|
pub(crate) fn rows_filtered(
|
|
caps: &[OpCapability],
|
|
keep: impl Fn(&OpCapability) -> bool,
|
|
curve_channel: usize,
|
|
) -> Vec<ParamRow> {
|
|
let mut rows = Vec::new();
|
|
for (op_index, op) in caps.iter().enumerate() {
|
|
if !keep(op) {
|
|
continue;
|
|
}
|
|
// 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();
|
|
// TRACES: FR-DEV-3
|
|
// Whether this group's heading carries the affordance that arms an
|
|
// on-canvas sampler. Set below, from what the operation asked for and
|
|
// nothing else — the panel never learns which operation it is.
|
|
let mut group_samples = false;
|
|
// 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 `ComposePanel` — 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).
|
|
//
|
|
// TRACES: FR-DEV-3
|
|
// **Unless the canvas is *reading* rather than driving.** An
|
|
// eyedropper writes temperature and tint and leaves them as
|
|
// the controls they were, so its group is drawn in full and
|
|
// only the affordance moves to the heading. See
|
|
// `samples_the_canvas` for the whole of that argument.
|
|
group_samples = samples_the_canvas(widget);
|
|
if widget.is_on_canvas() && !group_samples {
|
|
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, curve_channel)
|
|
}
|
|
// Canvas-hosted kinds returned above, except a
|
|
// sampler, which falls through to its own sliders; 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.
|
|
//
|
|
// Except when the parameter is the operation's only one. The panel
|
|
// draws no heading over a group of one, on the argument that a
|
|
// lone control names itself — and that holds only while the
|
|
// parameter is named after what it does. Three operations declare
|
|
// a single parameter called `amount`, which is the name
|
|
// `ops/README.md` tells an author to reach for first, and they
|
|
// arrived in the panel as three consecutive sliders all labelled
|
|
// "Amount" with nothing to tell them apart.
|
|
//
|
|
// So a lone parameter is titled by its operation. That is the name
|
|
// the missing heading would have carried, and for the operations
|
|
// whose one parameter already shares the operation's name it reads
|
|
// exactly as it did before.
|
|
let param_label = match &p.facet {
|
|
Some(f) => labels::resolve(f.subject.0),
|
|
None if op.params.len() == 1 => labels::resolve(op.label.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,
|
|
group_samples,
|
|
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 {
|
|
choices_model(&choices)
|
|
},
|
|
});
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
/// 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.
|
|
pub(super) 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.
|
|
pub(super) fn unit_suffix(unit: Unit) -> &'static str {
|
|
match unit {
|
|
Unit::None => "",
|
|
Unit::Stops => " EV",
|
|
Unit::Kelvin => " K",
|
|
Unit::Percent => "%",
|
|
}
|
|
}
|
|
|
|
impl DevelopSession {
|
|
/// 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.scoped_capabilities();
|
|
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
|
return;
|
|
};
|
|
if !self.active_masks.is_empty() {
|
|
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
|
|
let id = cap.id.0;
|
|
for layer in self.active_layers_mut() {
|
|
for &(param, default) in ¶ms {
|
|
layer.set_param(id, param, default);
|
|
}
|
|
}
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::RESET_OP));
|
|
return;
|
|
}
|
|
for p in &cap.params {
|
|
self.graph.set_param(cap.id, p.id, p.default);
|
|
}
|
|
// One step, though it moved every parameter the operation has: the
|
|
// user pressed one button.
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::RESET_OP));
|
|
}
|
|
|
|
/// 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;
|
|
};
|
|
if !self.active_masks.is_empty() {
|
|
// Every selected layer is set to the same absolute value the
|
|
// slider now shows, not offset by however far each one already
|
|
// was from it — the slider has one position, and "apply this
|
|
// reading to all of them" is the reading a photographer gets
|
|
// from watching it move.
|
|
for layer in self.active_layers_mut() {
|
|
layer.set_param(op.0, param, value);
|
|
}
|
|
// Coalesced the same way a global drag is: a slider dragged across
|
|
// masked layers is still one gesture and must undo as one.
|
|
let edit = Edit::for_param(&self.graph, op, param);
|
|
self.history.record(&self.graph, edit);
|
|
return;
|
|
}
|
|
|
|
self.graph.set_param(op, param, value);
|
|
let edit = Edit::for_param(&self.graph, op, param);
|
|
self.history.record(&self.graph, edit);
|
|
}
|
|
|
|
/// 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);
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::RESET_PARAM));
|
|
}
|
|
|
|
pub fn reset_all(&mut self) {
|
|
self.graph.reset();
|
|
self.history
|
|
.record(&self.graph, Edit::Action(labels::step::RESET_ALL));
|
|
}
|
|
|
|
pub(super) 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. Taken from whichever scope `rows`
|
|
// last described — the indices the interface is holding are positions
|
|
// in *that* list, and reading the global chain while a layer is
|
|
// selected would map a slider onto a different operation.
|
|
// The *unfiltered* scoped list, because `op_index` counts over every
|
|
// capability — see `rows_filtered`. Indexing a filtered list here is
|
|
// how a slider would drive the wrong operation once a tab is chosen.
|
|
let caps = self.scoped_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))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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:?}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DEV-3a
|
|
/// A canvas *sampler* adds an affordance; it does not take the sliders.
|
|
///
|
|
/// The distinction the panel had been missing. `is_on_canvas` was read as
|
|
/// "and so the panel draws nothing", which is right for a crop and wrong
|
|
/// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and**
|
|
/// picker", and a picker that ate the two sliders would have traded one
|
|
/// control for another. Asserted against the chain rather than a literal,
|
|
/// so it keeps testing the property when the declaration moves.
|
|
#[test]
|
|
fn a_sampled_operation_keeps_the_sliders_it_writes() {
|
|
let graph = EditGraph::default_chain();
|
|
let caps = graph.capabilities();
|
|
|
|
let (index, cap) = caps
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, c)| {
|
|
c.presentation
|
|
.as_ref()
|
|
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
|
|
})
|
|
.expect("some operation asks to be driven by a pixel");
|
|
|
|
let rows = rows_from(&caps);
|
|
let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect();
|
|
|
|
assert_eq!(
|
|
mine.len(),
|
|
cap.params.len(),
|
|
"every parameter of a sampled operation still has its own control"
|
|
);
|
|
assert!(
|
|
mine.iter().all(|r| r.group_samples),
|
|
"and the group's heading carries the picker"
|
|
);
|
|
assert!(
|
|
rows.iter()
|
|
.filter(|r| r.op_index != index as i32)
|
|
.all(|r| !r.group_samples),
|
|
"no other group claims one"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn framing_is_not_generated_as_sliders() {
|
|
// `ComposePanel` 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: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
|
demand: WidgetDemand {
|
|
two_dimensional: true,
|
|
precise_pointing: false,
|
|
},
|
|
params: vec![ParamId("a"), ParamId("b")],
|
|
}),
|
|
params: vec![param("a"), param("b")],
|
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
|
};
|
|
|
|
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-3a
|
|
/// A declared switch reaches the panel as a switch.
|
|
///
|
|
/// `ParamKind::Bool` was in the core's closed enum, was mapped to the row
|
|
/// kind `"bool"` here, and had no control behind it in `adjust.slint` —
|
|
/// which meant a parameter declaring itself a switch was flattened into a
|
|
/// row that drew nothing at all. Nothing shipped had one until the lens
|
|
/// profile did, so the gap cost nothing and was invisible.
|
|
///
|
|
/// This asserts the Rust half; the Slint half is the `if kind == "bool"`
|
|
/// branch in the control registry, which this cannot reach.
|
|
#[test]
|
|
fn a_switch_becomes_a_switch_row() {
|
|
use dr_pipeline::{LocalizedKey, ParamCapability};
|
|
|
|
let switched = OpCapability {
|
|
id: OpId("invented_switch"),
|
|
label: LocalizedKey("op.invented_switch"),
|
|
active: true,
|
|
presentation: None,
|
|
params: vec![ParamCapability {
|
|
id: ParamId("engaged"),
|
|
label: LocalizedKey("param.invented_switch.engaged"),
|
|
kind: ParamKind::Bool,
|
|
// On by default, which is the shape a correction the file
|
|
// itself asked for takes: off is the edit.
|
|
default: 1.0,
|
|
value: 0.0,
|
|
facet: None,
|
|
}],
|
|
attributes: vec![dr_pipeline::Attribute::Optics],
|
|
};
|
|
|
|
let rows = rows_from(&[switched]);
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].kind, "bool");
|
|
assert_eq!(rows[0].value, 0.0);
|
|
assert_eq!(rows[0].default_value, 1.0);
|
|
// A lone parameter is titled by its operation, so the box carries the
|
|
// name the withheld heading would have.
|
|
assert_eq!(
|
|
rows[0].param_label,
|
|
labels::resolve("op.invented_switch").as_str()
|
|
);
|
|
assert!(
|
|
rows[0].group_modified,
|
|
"a switch turned off differs from its default like any other \
|
|
parameter, and the group's marker has to say so"
|
|
);
|
|
}
|
|
|
|
/// 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: vec![
|
|
LocalizedKey("param.invented.method.fast"),
|
|
LocalizedKey("param.invented.method.exact"),
|
|
],
|
|
},
|
|
default: 0.0,
|
|
value: 1.0,
|
|
facet: None,
|
|
},
|
|
],
|
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
|
};
|
|
|
|
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: vec![WidgetKind::ColourWheel],
|
|
demand: WidgetDemand {
|
|
two_dimensional: true,
|
|
precise_pointing: false,
|
|
},
|
|
params: vec![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,
|
|
},
|
|
],
|
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
|
};
|
|
|
|
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 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 a_lone_parameter_is_named_after_its_operation() {
|
|
let graph = EditGraph::default_chain();
|
|
let caps = graph.capabilities();
|
|
let rows = rows_filtered(&caps, |_| true, 0);
|
|
|
|
for (i, cap) in caps.iter().enumerate() {
|
|
if cap.params.len() != 1 || cap.presentation.is_some() {
|
|
continue;
|
|
}
|
|
let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else {
|
|
continue;
|
|
};
|
|
assert_eq!(
|
|
row.param_label,
|
|
labels::resolve(cap.label.0).as_str(),
|
|
"a lone parameter must carry its operation's name, or the \
|
|
heading the panel withheld takes the name with it"
|
|
);
|
|
}
|
|
|
|
// And the specific collision that started this: no two rows drawn
|
|
// without a heading may read the same.
|
|
let bare: Vec<_> = rows
|
|
.iter()
|
|
.filter(|r| r.group_len == 1)
|
|
.map(|r| r.param_label.to_string())
|
|
.collect();
|
|
let mut unique = bare.clone();
|
|
unique.sort();
|
|
unique.dedup();
|
|
assert_eq!(
|
|
bare.len(),
|
|
unique.len(),
|
|
"two headingless rows share a label: {bare:?}"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|
|
}
|
|
}
|