//! 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 { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::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 { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::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 { use std::cell::RefCell; use std::collections::HashMap; thread_local! { static CACHE: RefCell>> = 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 { 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 { 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 = 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 { let mut aspects: Vec<&str> = Vec::new(); let rank: Vec = 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 = (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 = 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::>(), "{} 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::>(), "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); } } } }