//! The tone-curve widget: one run of points per subject, and the session's //! curve-channel selection that picks which one is being dragged. use dr_pipeline::ops::curve; use dr_pipeline::{OpCapability, Presentation, WidgetKind}; use crate::labels; use crate::ParamRow; #[cfg(test)] use super::rows::rows_filtered; #[cfg(test)] use super::rows::rows_from; use super::rows::{no_choices, supported}; use super::session::DevelopSession; #[cfg(test)] use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit}; /// One run of a curve widget's parameters: the points of a single curve. /// /// A widget may span several curves — the tone curve is one plot over a master /// curve and three colour channels — and it says so the way the colour mixer /// says it has twelve bands: by faceting each parameter with the *subject* it /// acts on. Consecutive parameters sharing a subject are one curve. struct CurveRun { /// The subject's localisation key, or `None` where the widget's parameters /// carry no facet at all and are therefore a single unnamed curve. subject: Option<&'static str>, /// Where this run's points begin in the operation's parameter list. What /// a drag routes back through, so it must be a position in `op.params` /// and not in the presentation's list. base: usize, /// How many coordinates it holds. len: usize, } /// TRACES: FR-DEV-3a /// The curves a curve widget spans, in the order the operation declares them. /// /// **This is the whole of the panel's knowledge of colour channels: none.** It /// groups by whatever subject the parameters carry, so an operation offering a /// master curve and three channels gets a four-way selector, one offering a /// single unfaceted curve gets no selector at all, and one that grows a fifth /// curve tomorrow needs no change here. /// /// Returns `None` where the parameters do not look like point coordinates — /// an odd count, a run that is not contiguous in the capability list — in /// which case the caller falls back to sliders rather than drawing a widget /// over a layout it has guessed at. fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { // Points are x/y pairs, so an odd count means the operation and this code // disagree about the layout. if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { log::warn!("{}: curve widget needs an even parameter count", op.id); return None; } let mut runs: Vec = Vec::new(); for id in &presentation.params { // The widget addresses points by offset from the first of its run, so // a run has to be contiguous in the capability list. let at = op.params.iter().position(|p| p.id == *id)?; let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); match runs.last_mut() { Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, _ => runs.push(CurveRun { subject, base: at, len: 1, }), } } if runs.iter().any(|r| !r.len.is_multiple_of(2)) { log::warn!("{}: a curve's points are not contiguous", op.id); return None; } Some(runs) } /// One row standing for a whole curve. /// /// `channel` picks which of the widget's curves is plotted; it is clamped /// rather than validated, because the selection is interface state that /// outlives a change of photograph and the new image's operation may have /// fewer curves than the old one's. /// /// Returns `None` if the operation's parameters do not look like point /// coordinates, in which case the caller falls back to sliders rather than /// rendering a broken widget. pub(super) fn curve_row( op_index: usize, group_head: usize, op: &OpCapability, presentation: &Presentation, channel: usize, ) -> Option { let runs = curve_runs(op, presentation)?; let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; let points: Vec = op.params[run.base..run.base + run.len] .iter() .map(|p| p.value) .collect(); Some(ParamRow { op_index: op_index as i32, // The first point parameter *of the curve on show*; the widget offsets // from here, so switching curve is what re-points the drag. param_index: run.base as i32, op_label: labels::resolve(op.label.0).into(), param_label: String::new().into(), // A widget spanning a whole operation is not a row in anyone's // grid, so it heads no run and carries no swatch. facet_label: String::new().into(), starts_facet: false, swatch_hue: -1.0, group_head: group_head as i32, // One widget standing for every parameter of the operation, so // the group it heads is itself and nothing else. group_len: 1, group_modified: op.params.iter().any(|p| p.value != p.default), // A curve is drawn, not sampled. Its own affordance is the plot. group_samples: false, kind: "curve".into(), value: 0.0, default_value: 0.0, minimum: 0.0, maximum: 1.0, precision: 4, unit: String::new().into(), points: slint::ModelRc::new(slint::VecModel::from(points)), // A curve is not a choice between named alternatives. The curves it // can switch between are named on the panel rather than on the row — // see `DevelopSession::curve_channels` for why they cannot ride here. choices: no_choices(), }) } /// Sort ascending and force a minimum separation. /// /// Mirrors what the curve operation does before handing points to the /// shader. Duplicated rather than shared because the operation keeps it /// private, and the consequence of drift is only a drawn line that lags the /// rendered one by a pixel — not a wrong image. fn sort_with_gap(xs: &mut [f32]) { const MIN_GAP: f32 = 0.001; for i in 1..xs.len() { let mut j = i; while j > 0 && xs[j - 1] > xs[j] { xs.swap(j - 1, j); j -= 1; } } for i in 1..xs.len() { if xs[i] - xs[i - 1] < MIN_GAP { xs[i] = xs[i - 1] + MIN_GAP; } } } impl DevelopSession { /// TRACES: FR-DEV-3 /// The names of the curves the widget can switch between. /// /// Empty where there is only one, which is also the answer for a frontend /// with no curve at all: a selector over a single choice is a row of /// nothing. /// /// **Derived from the facets, so nothing here names a colour channel.** /// The operation says its forty points are one control applied to four /// subjects and publishes a localisation key for each; this resolves the /// keys and hands over four words. An operation that grew a fifth curve /// would appear here on its own. /// /// A panel property rather than a field on the curve's `ParamRow`, and the /// reason is Slint's: a row's models are compared by identity, so a fresh /// list of names built on every parameter event would make the row look /// changed every time, and rewriting a row rebuilds the repeater item /// underneath it — destroying the `TouchArea` holding the drag in /// progress. The same hazard `rows`'s in-place point update exists to /// avoid. Nothing in this list is a drag target, so up here it is safe to /// replace wholesale, exactly as [`Self::curve_samples`] is. pub fn curve_channels(&self) -> Vec { for op in &self.scoped_capabilities() { let Some(presentation) = &op.presentation else { continue; }; if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { continue; } let Some(runs) = curve_runs(op, presentation) else { continue; }; if runs.len() < 2 { continue; } return runs .iter() .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) .collect(); } Vec::new() } /// Which curve the widget is plotting, as an index into /// [`Self::curve_channels`]. pub fn curve_channel(&self) -> i32 { self.curve_channel as i32 } /// Plot a different one of the operation's curves. /// /// Out-of-range indices are ignored rather than clamped: the only thing /// that can send one is a stale interface event, and quietly moving the /// selection somewhere the user did not point is worse than doing nothing. pub fn set_curve_channel(&mut self, index: i32) { let Ok(index) = usize::try_from(index) else { return; }; if index < self.curve_channels().len() { self.curve_channel = index; } } /// The plotted curve's shape, sampled for drawing. /// /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags /// is the line the shader applies. The alternative — reading the curve /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a /// polyline. /// /// The line drawn is the *selected* curve's own shape, not the composition /// of it with the master. Two curves overlaid on one grid is a plot of two /// things, and the one being dragged has to be the one whose points are /// under the pointer. pub fn curve_samples(&self) -> Vec { const SAMPLES: usize = 96; // The selection is an index over the subjects the panel found, which // for this operation is its channel order. Clamped rather than // trusted: a selection made on one photograph outlives the change to // the next. let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; let mut found = false; for cap in self.graph.capabilities() { if cap.id != curve::ID { continue; } found = true; // By id rather than by position, so which curve is plotted is // decided by naming it and not by arithmetic over the parameter // list. let value = |id| { cap.params .iter() .find(|p| p.id == id) .map_or(0.0, |p| p.value) }; for i in 0..curve::POINTS { xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); } } if !found { return Vec::new(); } // Sorted the same way the operation sorts before handing points to // the shader, or a dragged-past point would draw differently from // how it renders. sort_with_gap(&mut xs); (0..SAMPLES) .map(|i| { let x = i as f32 / (SAMPLES - 1) as f32; curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0) }) .collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn the_curve_collapses_to_a_single_row() { // Every point parameter — all four curves' worth — must appear as one // curve control, not as forty sliders. Otherwise the widget and the // sliders both render and the panel shows the same values twice. let graph = EditGraph::default_chain(); let curve_cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("the chain includes a tone curve"); assert_eq!( curve_cap.params.len(), curve::CHANNELS * curve::POINTS * 2, "a master curve and one per colour channel" ); let presentation = curve_cap .presentation .as_ref() .expect("the curve declares a widget"); // Asked the way the panel asks it: the first preference this frontend // implements, not a fixed single kind. assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve)); // Every parameter is owned by the widget, so none is left over to be // rendered as a stray slider. assert_eq!(presentation.params.len(), curve_cap.params.len()); } #[test] fn curve_point_parameters_are_contiguous() { // The widget addresses points by offset from the first. Were they // interleaved with anything else, dragging a point would write to // the wrong parameter. let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("tone curve present"); let presentation = cap.presentation.as_ref().expect("declares a widget"); let base = cap .params .iter() .position(|p| p.id == presentation.params[0]) .expect("first point is a parameter"); for (i, id) in presentation.params.iter().enumerate() { assert_eq!( cap.params[base + i].id, *id, "point parameter {i} is out of order" ); } } /// The panel's whole knowledge of colour channels, asserted to be none. /// /// It groups the widget's parameters by the subject the *operation* put on /// them and finds four curves; nothing below says "red", and an operation /// that grew a fifth curve would arrive here on its own. #[test] fn a_curve_widget_offers_one_run_per_subject() { let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.id == curve::ID) .expect("tone curve present"); let presentation = cap.presentation.as_ref().expect("declares a widget"); let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); assert_eq!(runs.len(), curve::CHANNELS); for (i, run) in runs.iter().enumerate() { assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); assert_eq!(run.base, i * curve::POINTS * 2); assert!(run.subject.is_some(), "run {i} is unnamed"); } } #[test] fn switching_curve_repoints_the_row() { use slint::Model as _; // What a drag routes through. The row's `param_index` is the base of // the curve *on show*, so picking a different one must move it — if it // did not, dragging a point on the red curve would write to the // master's. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let curve_at = caps .iter() .position(|c| c.id == curve::ID) .expect("tone curve present"); let mut bases = Vec::new(); for channel in 0..curve::CHANNELS { let rows = rows_filtered(&caps, |_| true, channel); let row = rows .iter() .find(|r| r.op_index as usize == curve_at) .expect("the curve has a row"); assert_eq!(row.kind, "curve"); assert_eq!( row.points.row_count(), curve::POINTS * 2, "one curve's points, not all four curves'" ); bases.push(row.param_index); } assert_eq!( bases, (0..curve::CHANNELS) .map(|i| (i * curve::POINTS * 2) as i32) .collect::>() ); } #[test] fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { // The selection outlives the photograph it was made on, and the next // image's operation may offer fewer curves. Clamping keeps a plot on // the grid; the alternative is a curve row that vanishes, which reads // as the tone curve having disappeared from the panel. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); let rows = rows_filtered(&caps, |_| true, 99); let row = rows .iter() .find(|r| r.kind == "curve") .expect("the curve still has a row"); assert_eq!( row.param_index, ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 ); } #[test] fn an_operation_whose_points_are_unfaceted_is_one_curve() { // A curve widget that spans a single unnamed curve — which is what // this operation was before the channels arrived, and what any other // node declaring a `tone_curve` widget over ten scalars would be. // It must draw, and it must offer no choice. use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; use slint::Model as _; static IDS: [ParamId; 4] = [ ParamId("p0_x"), ParamId("p0_y"), ParamId("p1_x"), ParamId("p1_y"), ]; let param = |id: ParamId| ParamCapability { id, label: LocalizedKey("param.point"), kind: ParamKind::Scalar { min: 0.0, max: 1.0, scale: dr_pipeline::Scale::Linear, unit: Unit::None, precision: 4, }, default: 0.0, value: 0.0, facet: None, }; let plain = OpCapability { id: OpId("invented_curve"), label: LocalizedKey("op.invented_curve"), active: false, presentation: Some(Presentation { widgets: vec![WidgetKind::ToneCurve], demand: WidgetDemand { two_dimensional: true, precise_pointing: true, }, params: IDS.to_vec(), }), params: IDS.iter().map(|id| param(*id)).collect(), attributes: vec![dr_pipeline::Attribute::Tone], }; let presentation = plain.presentation.as_ref().expect("declares a widget"); let runs = curve_runs(&plain, presentation).expect("curve-shaped"); assert_eq!(runs.len(), 1, "one unnamed curve"); assert_eq!(runs[0].subject, None); let rows = rows_from(&[plain]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].kind, "curve"); assert_eq!(rows[0].points.row_count(), IDS.len()); } #[test] fn curve_samples_start_on_the_diagonal() { // A fresh curve is the identity, so the drawn line must be the 45° // diagonal — anything else means the widget opens showing a shape // the image does not have. let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; for i in 0..curve::POINTS { let t = i as f32 / (curve::POINTS - 1) as f32; xs[i] = t; ys[i] = t; } for i in 0..=20 { let x = i as f32 / 20.0; let y = curve::evaluate(&xs, &ys, x); assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}"); } } #[test] fn sorting_enforces_a_minimum_gap() { // Two points dragged onto each other would divide by zero in the // spline; the drawn curve must survive it exactly as the shader does. let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5]; sort_with_gap(&mut xs); for i in 1..xs.len() { assert!(xs[i] > xs[i - 1], "not separated: {xs:?}"); } } #[test] fn sorting_orders_reversed_points() { let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1]; sort_with_gap(&mut xs); for i in 1..xs.len() { assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); } } }