diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 7367f3c..f90d10e 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -92,6 +92,20 @@ pub struct DevelopSession { /// attributes leaves it at — the tabs are the interface's idea, not the /// core's, and nothing breaks without them (ARCH §4.3a). active_tab: Option, + /// TRACES: FR-DEV-3 + /// Which of the curve widget's subjects the panel is plotting. + /// + /// The tone curve is four curves — one over tone and one per colour + /// channel — and one square plot draws one of them at a time. The index + /// is into the subjects the operation's parameters are faceted on, in the + /// order it declares them, so nothing here knows that "red" exists. + /// + /// **Interface state, not part of the edit.** It changes no pixel, so it + /// is not a parameter, it is not in the graph, it is not in the sidecar + /// and it is not on the undo stack — the same standing as which tab is + /// open. One value rather than one per operation, for the same reason + /// `curve_samples` is one polyline: the panel draws one curve. + curve_channel: usize, } impl DevelopSession { @@ -157,6 +171,7 @@ impl DevelopSession { active_mask: None, show_overlay: false, active_tab: None, + curve_channel: 0, } } @@ -167,8 +182,12 @@ impl DevelopSession { pub fn rows(&self) -> Vec { let caps = self.scoped_capabilities(); match self.active_tab { - Some(attribute) => rows_filtered(&caps, |op| op.attributes.contains(&attribute)), - None => rows_from(&caps), + Some(attribute) => rows_filtered( + &caps, + |op| op.attributes.contains(&attribute), + self.curve_channel, + ), + None => rows_filtered(&caps, |_| true, self.curve_channel), } } @@ -202,8 +221,10 @@ impl DevelopSession { .into_iter() .filter(|a| *a != Attribute::Geometry) .filter(|a| { - caps.iter() - .any(|c| c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a)).is_empty()) + caps.iter().any(|c| { + c.attributes.contains(a) + && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() + }) }) .map(|a| (a, crate::labels::resolve(a.label().0))) .collect() @@ -312,8 +333,13 @@ pub(crate) fn supported(widget: WidgetKind) -> bool { /// 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) + rows_filtered(caps, |_| true, 0) } /// The panel model for the capabilities `keep` accepts. @@ -326,9 +352,16 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { /// /// 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() { @@ -376,7 +409,9 @@ pub(crate) fn rows_filtered( // 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), + WidgetKind::ToneCurve => { + curve_row(op_index, group_head, op, presentation, curve_channel) + } // Canvas-hosted kinds returned above; the rest are not // implemented and reached sliders via `choose`. WidgetKind::ColourWheel @@ -489,8 +524,76 @@ pub(crate) fn rows_filtered( rows } +/// 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. @@ -499,38 +602,21 @@ fn curve_row( group_head: usize, op: &OpCapability, presentation: &Presentation, + channel: usize, ) -> 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 runs = curve_runs(op, presentation)?; + let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; - // The widget addresses points by offset from the first, so they must - // be contiguous in the capability list. - let base = op - .params + let points: Vec = op.params[run.base..run.base + run.len] .iter() - .position(|p| p.id == presentation.params[0])?; - for (i, id) in presentation.params.iter().enumerate() { - if op.params.get(base + i).map(|p| p.id) != Some(*id) { - log::warn!("{}: curve parameters are not contiguous", op.id); - return None; - } - } - - let points: Vec = presentation - .params - .iter() - .filter_map(|id| op.params.iter().find(|p| p.id == *id)) .map(|p| p.value) .collect(); Some(ParamRow { op_index: op_index as i32, - // The first point parameter; the widget offsets from here. - param_index: base as i32, + // 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 @@ -551,21 +637,97 @@ fn curve_row( precision: 4, unit: String::new().into(), points: slint::ModelRc::new(slint::VecModel::from(points)), - // A curve is not a choice between named alternatives. + // 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(), }) } impl DevelopSession { - /// The curve's shape, sampled for drawing. + /// 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; @@ -575,16 +737,18 @@ impl DevelopSession { continue; } found = true; - for (i, p) in cap.params.iter().enumerate() { - let point = i / 2; - if point >= curve::POINTS { - break; - } - if i % 2 == 0 { - xs[point] = p.value; - } else { - ys[point] = p.value; - } + // 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 { @@ -2977,9 +3141,9 @@ mod tests { #[test] fn the_curve_collapses_to_a_single_row() { - // Ten point parameters must appear as one curve control, not ten - // sliders — otherwise the widget and the sliders both render and the - // panel shows the same values twice. + // 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() @@ -2987,7 +3151,11 @@ mod tests { .find(|c| c.id == curve::ID) .expect("the chain includes a tone curve"); - assert_eq!(curve_cap.params.len(), curve::POINTS * 2); + 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() @@ -3027,6 +3195,144 @@ mod tests { } } + /// 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: &[WidgetKind::ToneCurve], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: true, + }, + params: &IDS, + }), + params: IDS.iter().map(|id| param(*id)).collect(), + attributes: &[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° diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 4d2f242..f40c076 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -54,6 +54,19 @@ pub fn resolve(key: &str) -> String { "param.channel.sat" => "Saturation".into(), "param.channel.lum" => "Luminance".into(), + // The tone curve's four curves, which its points are *subject* to. + // + // Catalogued rather than derived because the master curve's key would + // otherwise read "Rgb": these are the terms of a four-way choice, and + // one of them miscapitalised is the one the eye goes to. The three + // colours would derive correctly and are written out beside it anyway, + // since a list where one entry is translated and three are guessed is + // the shape a half-finished translation takes. + "channel.rgb" => "RGB".into(), + "channel.red" => "Red".into(), + "channel.green" => "Green".into(), + "channel.blue" => "Blue".into(), + // The hue bands, which a faceted row is *subject* to. // // Catalogued even where `derive` would produce the same word, because diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index c7d45ae..55b3d8e 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -581,8 +581,24 @@ pub(crate) fn sync_rows( // curve must be drawn whatever shape it is in. rows.set_vec(current); curve_moved = true; + + // The curves the widget can switch between, named. They can only + // change with the operation set, which is what this branch means, so + // the walk that derives them is not on the parameter-event path. + let channels: Vec = match session.borrow().as_ref() { + Some(s) => s.curve_channels().into_iter().map(Into::into).collect(), + None => Vec::new(), + }; + window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels))); } + // Which curve is plotted, on every pass. Picking one that happens to be + // shaped like the last — two untouched curves are both the diagonal — + // moves no point, so this cannot ride on the resample below: the chips + // would go on highlighting the curve the user just navigated away from. + let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel()); + window.set_curve_channel(channel); + if !curve_moved { return; } @@ -1802,6 +1818,24 @@ pub fn run(paths: Vec) -> Result<()> { redraw(&w); }); } + { + // Which of the curve's curves the plot is showing. **No redraw**, and + // that is the whole character of this control: it changes no + // parameter, so the photograph is already correct on screen and + // recomputing it would be a frame spent to produce the same pixels. + // For the same reason it records no history step — there is nothing + // to undo — and the sidecar never hears about it. + let weak = window.as_weak(); + let session = session.clone(); + let rows = rows.clone(); + window.on_curve_channel_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_curve_channel(index); + } + sync_rows(&w, &rows, &session); + }); + } // ---- undo and redo (FR-DEV-5) --------------------------------------- // diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 8d81154..49f8f8a 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -365,10 +365,17 @@ component ParamControl inherits Rectangle { in property data; /// Curve rows only; ignored by every other kind. in property <[float]> curve-samples; + /// The curves this widget can plot, named. Empty, or one entry, where + /// there is nothing to choose between — see `curve-channel-picked`. + in property <[string]> curve-channels; + /// Which of `curve-channels` is on the grid. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the operation's curves. + callback curve-channel-picked(int); callback drag-changed(bool); height: layout.preferred-height; @@ -420,20 +427,49 @@ component ParamControl inherits Rectangle { } } - if root.data.kind == "curve": CurveEditor { - points: root.data.points; - samples: root.curve-samples; - drag-changed(on) => { root.drag-changed(on); } - // A point carries two parameters, so the parameter index is the - // row's base plus the point's offset. This component still knows - // nothing about which operation it belongs to. - point-moved(point, x, y) => { - root.param-changed( - root.data.op-index, root.data.param-index + point * 2, x); - root.param-changed( - root.data.op-index, root.data.param-index + point * 2 + 1, y); + // A curve, and — where the operation offers more than one — the choice + // of which curve is on the grid. + // + // One plot rather than four stacked ones: the curves are read against + // the diagonal and against each other, which needs the grid large, and + // four grids at a quarter of the width would each be too small to + // place a point in. So the selector switches the subject of a single + // plot, and the names in it come from the core — this file does not + // know that a colour channel is what is being chosen between, only + // that the widget said it spans several named things. + if root.data.kind == "curve": VerticalLayout { + spacing: Theme.gap-sm; + + if root.curve-channels.length > 1: Segmented { + // The operation's own name, which nothing else in this row + // draws: a curve row heads no group, so without this the plot + // would sit in the panel unlabelled. + label: root.data.op-label; + options: root.curve-channels; + selected: root.curve-channel; + picked(i) => { root.curve-channel-picked(i); } + } + + CurveEditor { + points: root.data.points; + samples: root.curve-samples; + drag-changed(on) => { root.drag-changed(on); } + // A point carries two parameters, so the parameter index is + // the row's base plus the point's offset. The base is the + // first point of the curve *on show*, so switching curve + // re-points the drag and this component still knows nothing + // about which operation — or which curve — it is drawing. + point-moved(point, x, y) => { + root.param-changed( + root.data.op-index, root.data.param-index + point * 2, x); + root.param-changed( + root.data.op-index, root.data.param-index + point * 2 + 1, y); + } + // Resetting a curve resets the operation, which is all four of + // them — a photographer who double-clicks to start again means + // the control, not the curve that happens to be on show. + reset => { root.curve-reset(root.data.op-index); } } - reset => { root.curve-reset(root.data.op-index); } } } } @@ -710,9 +746,16 @@ export component AdjustPanel inherits Rectangle { /// The tone curve's sampled shape, evaluated in Rust by the same spline /// the shader runs so the drawn line cannot disagree with the applied one. in property <[float]> curve-samples; + /// The curves the tone curve widget can plot, named by the core. Fewer + /// than two of them means there is nothing to choose and no selector. + in property <[string]> curve-channels; + /// Which of them `curve-samples` and the row's points describe. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the curve's curves. + callback curve-channel-picked(int); /// Return every parameter of one operation to its default — the reset on /// a section's own header, beside the panel-wide one. callback op-reset(int); @@ -861,12 +904,15 @@ export component AdjustPanel inherits Rectangle { ParamControl { data: row; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; drag-changed(on) => { root.slider-dragging = on; } param-changed(op, param, v) => { root.param-changed(op, param, v); } param-reset(op, param) => { root.param-reset(op, param); } curve-reset(op) => { root.curve-reset(op); } + curve-channel-picked(i) => { root.curve-channel-picked(i); } } } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 8460f8e..6e1512b 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -680,9 +680,14 @@ export component AppWindow inherits Window { // The tone curve's sampled shape, evaluated by the core so the drawn // line and the applied one cannot disagree. in property <[float]> curve-samples; + // The curves that widget can plot, named by the core, and which of them + // `curve-samples` describes. Fewer than two means nothing to choose. + in property <[string]> curve-channels; + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + callback curve-channel-picked(int); callback reset-all(); // --- copying settings between photographs (FR-DEV-6) --- @@ -1978,6 +1983,11 @@ in property panel-visible: true; rows: root.adjust-rows; enabled: root.adjust-enabled; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; + curve-channel-picked(i) => { + root.curve-channel-picked(i); + } param-changed(op, param, value) => { root.param-changed(op, param, value); }