Sync with integration
This commit is contained in:
+352
-46
@@ -273,6 +273,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<dr_pipeline::Attribute>,
|
||||
/// 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 {
|
||||
@@ -339,6 +353,7 @@ impl DevelopSession {
|
||||
active_mask: None,
|
||||
show_overlay: false,
|
||||
active_tab: None,
|
||||
curve_channel: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +364,12 @@ impl DevelopSession {
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +403,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()
|
||||
@@ -494,8 +515,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<ParamRow> {
|
||||
rows_filtered(caps, |_| true)
|
||||
rows_filtered(caps, |_| true, 0)
|
||||
}
|
||||
|
||||
/// The panel model for the capabilities `keep` accepts.
|
||||
@@ -508,9 +534,16 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||||
///
|
||||
/// 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() {
|
||||
@@ -558,7 +591,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
|
||||
@@ -671,8 +706,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<Vec<CurveRun>> {
|
||||
// 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<CurveRun> = 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.
|
||||
@@ -681,38 +784,21 @@ fn curve_row(
|
||||
group_head: usize,
|
||||
op: &OpCapability,
|
||||
presentation: &Presentation,
|
||||
channel: usize,
|
||||
) -> Option<ParamRow> {
|
||||
// Points are x/y pairs, so an odd count means the operation and this
|
||||
// code disagree about the layout.
|
||||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||||
return None;
|
||||
}
|
||||
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<f32> = 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<f32> = presentation
|
||||
.params
|
||||
.iter()
|
||||
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
|
||||
.map(|p| p.value)
|
||||
.collect();
|
||||
|
||||
Some(ParamRow {
|
||||
op_index: op_index as i32,
|
||||
// The first point parameter; the widget offsets from here.
|
||||
param_index: base as i32,
|
||||
// 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
|
||||
@@ -733,21 +819,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<String> {
|
||||
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<f32> {
|
||||
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;
|
||||
@@ -757,16 +919,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 {
|
||||
@@ -3419,9 +3583,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()
|
||||
@@ -3429,7 +3593,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()
|
||||
@@ -3469,6 +3637,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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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°
|
||||
|
||||
@@ -61,6 +61,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
|
||||
|
||||
@@ -583,8 +583,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<slint::SharedString> = 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;
|
||||
}
|
||||
@@ -1804,6 +1820,24 @@ pub fn run(paths: Vec<PathBuf>) -> 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) ---------------------------------------
|
||||
//
|
||||
|
||||
+264
-1
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||||
use crate::{AppWindow, LibraryCell, TimelineBar};
|
||||
use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar};
|
||||
|
||||
/// Window size before the grid has reported its geometry.
|
||||
///
|
||||
@@ -2117,6 +2117,170 @@ fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||||
}
|
||||
|
||||
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
|
||||
//
|
||||
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
|
||||
// join, what a rename does to the assignments. This part owns the *interaction*:
|
||||
// which photographs the sheet is acting on, and keeping what it draws honest
|
||||
// about what actually landed.
|
||||
|
||||
/// Redraw the keywording sheet against whatever is selected now.
|
||||
///
|
||||
/// Called when the sheet opens and after every assignment, rather than on every
|
||||
/// selection change: the selection moves on each arrow key and the sheet is shut
|
||||
/// for almost all of them, so computing coverage over a forty-image selection
|
||||
/// on each one would be work nobody is looking at.
|
||||
///
|
||||
/// Re-read from the catalog rather than patched in place after a write. A word
|
||||
/// applied to a selection that partly already had it moves from "3 of 12" to
|
||||
/// "12 of 12", and a model updated by hand would have to reproduce the rule
|
||||
/// that decides that — which is exactly the rule the catalog has just applied.
|
||||
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
// The grid is entirely usable without the sheet, so this is logged
|
||||
// rather than surfaced: a keyword read that failed must not put an
|
||||
// error banner over a library the user is browsing.
|
||||
log::debug!("reading keywords: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let model: Vec<KeywordRow> = rows
|
||||
.into_iter()
|
||||
.map(|row| KeywordRow {
|
||||
id: row.keyword.id.0 as i32,
|
||||
name: row.keyword.name.into(),
|
||||
coverage: match row.coverage {
|
||||
dr_catalog::Coverage::None => 0,
|
||||
dr_catalog::Coverage::Some => 1,
|
||||
dr_catalog::Coverage::All => 2,
|
||||
},
|
||||
selected_count: row.selected_count as i32,
|
||||
image_count: row.keyword.image_count as i32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
|
||||
}
|
||||
|
||||
/// Put a keyword on the selection, or take it off.
|
||||
///
|
||||
/// # Why this does not write a sidecar
|
||||
///
|
||||
/// Every other judgement in this file — a star, a flag — is written to the
|
||||
/// catalog and then queued to the image's sidecar, because the sidecar is what
|
||||
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
|
||||
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
|
||||
/// `flag` and nothing else that is not an edit-graph parameter.
|
||||
///
|
||||
/// So a keyword is, for now, catalog state that reaches the user's other
|
||||
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
|
||||
/// through the sidecar. That is a real limitation and not a silent one: a
|
||||
/// deleted catalog loses keywords where it would keep ratings, until the
|
||||
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
|
||||
/// queued write the stars have.
|
||||
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
|
||||
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
let images = coll.selected();
|
||||
|
||||
// The word as it will be *stored*, resolved before anything is written.
|
||||
// The status line below quotes it back, and quoting what was typed would
|
||||
// report a leading space the catalog is about to drop — leaving the user to
|
||||
// wonder whether it mattered.
|
||||
//
|
||||
// This is also where a blank keyword is caught, which is why it happens
|
||||
// before the selection check: "you typed nothing" is a better answer than
|
||||
// "select an image first" to someone who pressed return on an empty field.
|
||||
let word = match dr_catalog::keywords::normalise(word) {
|
||||
Ok(word) => word,
|
||||
Err(e) => {
|
||||
// `BadName` carries text written to be read by the user rather than
|
||||
// by a developer, so it is shown as it is.
|
||||
window.set_library_error(format!("{e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Assigning with nothing selected still means something — it puts the word
|
||||
// in the vocabulary, ready for the photographs it was typed for — so only
|
||||
// the removal half needs a selection to act on.
|
||||
if images.is_empty() && !assigning {
|
||||
window.set_library_status("Select an image first".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let outcome = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let conn = catalog.connection();
|
||||
if assigning {
|
||||
dr_catalog::keywords::assign(conn, &images, &word)
|
||||
} else {
|
||||
dr_catalog::keywords::unassign(conn, &images, &word)
|
||||
}
|
||||
};
|
||||
|
||||
let n = match outcome {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("{e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||||
refresh_keywords(window, ctl, &images);
|
||||
|
||||
// A filtered grid may no longer hold what was just keyworded — taking
|
||||
// "puffin" off an image while showing only puffins means it belongs
|
||||
// elsewhere now. The same reasoning as a rating that falls below the star
|
||||
// filter.
|
||||
if !ctl.filter.borrow().is_unfiltered() {
|
||||
load_window(window, ctl);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status line says about a keyword that just landed.
|
||||
///
|
||||
/// The honest count, not the requested one: "added to 3 of 12" is what
|
||||
/// happened when nine of them already carried the word, and a message that
|
||||
/// claimed twelve would be teaching the user that the counts are decorative.
|
||||
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
|
||||
if selected == 0 {
|
||||
return format!("Added “{word}” to the keyword list");
|
||||
}
|
||||
let verb = if assigning { "Added" } else { "Removed" };
|
||||
let preposition = if assigning { "to" } else { "from" };
|
||||
if changed == 0 {
|
||||
return if assigning {
|
||||
format!("Every selected photograph already had “{word}”")
|
||||
} else {
|
||||
format!("None of the selected photographs had “{word}”")
|
||||
};
|
||||
}
|
||||
if changed == selected {
|
||||
let what = if selected == 1 {
|
||||
"1 photograph".to_string()
|
||||
} else {
|
||||
format!("{selected} photographs")
|
||||
};
|
||||
return format!("{verb} “{word}” {preposition} {what}");
|
||||
}
|
||||
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||||
}
|
||||
|
||||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||||
///
|
||||
/// # Order matters
|
||||
@@ -4152,6 +4316,40 @@ pub fn wire<F>(
|
||||
});
|
||||
}
|
||||
|
||||
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
|
||||
//
|
||||
// Three callbacks and no state of their own: the sheet's open/shut is local
|
||||
// to the `.slint` file, and what a keyword applies to is the grid selection
|
||||
// the collections controller already owns. A second copy of either here is
|
||||
// a second thing that can disagree with the first.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll_for_keywords = coll_ctl.clone();
|
||||
window.on_library_keywords_opened(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_assign_keyword(move |word| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
apply_keyword(&w, &ctl, word.as_str(), true);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_unassign_keyword(move |word| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
apply_keyword(&w, &ctl, word.as_str(), false);
|
||||
});
|
||||
}
|
||||
|
||||
// --- the filter bar ---------------------------------------------------
|
||||
//
|
||||
// Each of these narrows what the grid *queries*, so all three reset the
|
||||
@@ -5017,6 +5215,71 @@ mod tests {
|
||||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||||
}
|
||||
|
||||
// --- what the status line says about a keyword (FR-CAT-5) -------------
|
||||
//
|
||||
// Split out from the callback for the same reason `decide_drop` is: the
|
||||
// sheet cannot be driven from a test, and this is the part that can
|
||||
// actually mislead someone.
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn a_partly_applied_keyword_reports_the_honest_count() {
|
||||
// Nine of the twelve already had it. Claiming twelve is how a user
|
||||
// learns that the counts are decorative.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 3, 12, true),
|
||||
"Added “puffin” to 3 of 12"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 12, true),
|
||||
"Every selected photograph already had “puffin”"
|
||||
);
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 12, false),
|
||||
"None of the selected photographs had “puffin”"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn one_photograph_is_singular() {
|
||||
// "Added to 1 photographs" is the kind of small wrongness that makes
|
||||
// the rest of the interface look unfinished.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 1, 1, true),
|
||||
"Added “puffin” to 1 photograph"
|
||||
);
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 2, 2, true),
|
||||
"Added “puffin” to 2 photographs"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn removing_a_keyword_reads_as_removal() {
|
||||
assert_eq!(
|
||||
keyword_summary("blurry", 4, 4, false),
|
||||
"Removed “blurry” from 4 photographs"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
|
||||
// It builds the vocabulary, which is a legitimate thing to do ahead of
|
||||
// a shoot — so it must not report itself as having keyworded nothing.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 0, true),
|
||||
"Added “puffin” to the keyword list"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
#[test]
|
||||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||||
|
||||
+59
-13
@@ -365,10 +365,17 @@ component ParamControl inherits Rectangle {
|
||||
in property <ParamRow> 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 <int> 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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -825,9 +861,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 <int> 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);
|
||||
@@ -986,12 +1029,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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -2,7 +2,7 @@ import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
@@ -589,6 +589,25 @@ export component AppWindow inherits Window {
|
||||
/// the target's id, and whether to take the images out of the collection
|
||||
/// currently being shown.
|
||||
callback library-file-in-collection(int, bool);
|
||||
/// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
/// Keywording the grid's selection. The catalog has been searchable by
|
||||
/// keyword since it existed and there was nowhere to type one; this is it.
|
||||
///
|
||||
/// The vocabulary arrives already answered against the selection — each row
|
||||
/// says how many of the selected photographs carry that word — because only
|
||||
/// Rust knows what is selected, and a `.slint` file counting it would need
|
||||
/// the selection as a second model that could disagree with the first.
|
||||
in property <[KeywordRow]> library-keywords;
|
||||
/// The sheet is opening: recompute the rows against the selection as it
|
||||
/// stands now. Pulled rather than pushed, because the selection changes on
|
||||
/// every arrow key and the sheet is shut for almost all of them.
|
||||
callback library-keywords-opened();
|
||||
/// Put a keyword on the selection, creating it if it is new. By name, so a
|
||||
/// word typed into the field and a word tapped in the list are one path.
|
||||
callback library-assign-keyword(string);
|
||||
/// Take a keyword off the selection. Never deletes the keyword itself —
|
||||
/// it stays in the vocabulary and on every other photograph that carries it.
|
||||
callback library-unassign-keyword(string);
|
||||
/// TRACES: FR-UI-2
|
||||
/// Whether a tap in the grid selects rather than opens, and the button
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
@@ -697,9 +716,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 <int> 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) ---
|
||||
@@ -1268,6 +1292,10 @@ in property <bool> panel-visible: true;
|
||||
file-in-collection(id, moves) => {
|
||||
root.library-file-in-collection(id, moves);
|
||||
}
|
||||
keywords: root.library-keywords;
|
||||
keywords-opened() => { root.library-keywords-opened(); }
|
||||
assign-keyword(word) => { root.library-assign-keyword(word); }
|
||||
unassign-keyword(word) => { root.library-unassign-keyword(word); }
|
||||
cursor: root.library-cursor;
|
||||
move-cursor(delta, extend) => {
|
||||
root.library-move-cursor(delta, extend);
|
||||
@@ -2154,6 +2182,11 @@ in property <bool> panel-visible: true;
|
||||
enabled: root.adjust-enabled;
|
||||
scope: root.adjust-scope;
|
||||
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);
|
||||
}
|
||||
|
||||
+269
-1
@@ -9,12 +9,38 @@
|
||||
// must not look identical (FR-NC-6c).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint";
|
||||
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint";
|
||||
// The filing sheet lists the same rows the sidebar draws, from the same model:
|
||||
// two lists of collections that could disagree about what exists is one list
|
||||
// too many.
|
||||
import { CollectionRow } from "collections.slint";
|
||||
|
||||
// TRACES: FR-CAT-5
|
||||
// One keyword in the keywording sheet, already answered against the selection.
|
||||
//
|
||||
// The three-way `coverage` is the whole reason this is a struct rather than a
|
||||
// list of strings. Applying a word to forty photographs where thirty already
|
||||
// carry it must not look like applying it to forty that carry none, and
|
||||
// removing one that only some of them carry must not silently claim to have
|
||||
// taken it off all forty. Rust computes it, because only Rust knows how big the
|
||||
// selection is and how many of it each word covers.
|
||||
export struct KeywordRow {
|
||||
// Row id in `keyword_terms`, or 0 for a word an image carries that the
|
||||
// vocabulary has no identity for yet. The sheet acts on `name`, never on
|
||||
// this, so a 0 costs nothing — it is here so a future rename gesture has
|
||||
// something to name.
|
||||
id: int,
|
||||
name: string,
|
||||
// 0 none of the selection, 1 some of it, 2 all of it.
|
||||
coverage: int,
|
||||
// How many of the selected photographs carry it, for the "3 of 12" that
|
||||
// makes `coverage: 1` a number rather than a shrug.
|
||||
selected-count: int,
|
||||
// How many photographs in the whole library carry it. Lets a word in
|
||||
// regular use be told from one typed once by mistake.
|
||||
image-count: int,
|
||||
}
|
||||
|
||||
// One bar of the capture-time histogram.
|
||||
export struct TimelineBar {
|
||||
// 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet
|
||||
@@ -656,6 +682,9 @@ component HeaderActions inherits HorizontalLayout {
|
||||
callback remove-from-collection();
|
||||
/// Open the sheet that files the selection in a collection.
|
||||
callback add-to-collection();
|
||||
/// TRACES: FR-CAT-5
|
||||
/// Open the sheet that keywords the selection.
|
||||
callback add-keyword();
|
||||
callback toggle-select-mode();
|
||||
callback change-library();
|
||||
callback toggle-pin-scope();
|
||||
@@ -694,6 +723,17 @@ component HeaderActions inherits HorizontalLayout {
|
||||
clicked => { root.add-to-collection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-5 | FR-CAT-6
|
||||
// Keyword the selection. Beside "Add to collection" because they are the
|
||||
// same thought — these photographs are *of* something, and they belong
|
||||
// *with* something — and appearing under the same condition, because
|
||||
// neither means anything without a selection to act on.
|
||||
if root.selected-count > 0: Button {
|
||||
text: "Keywords";
|
||||
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||
clicked => { root.add-keyword(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-apply the copied settings. Shown only with both a selection and a
|
||||
// clipboard, because it is meaningless without either — and because a
|
||||
@@ -1105,6 +1145,34 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// out of the one currently being shown.
|
||||
callback file-in-collection(int, bool);
|
||||
|
||||
// --- keywording the selection (FR-CAT-5, FR-CAT-6) ----------------------
|
||||
//
|
||||
// The catalog has been searchable by keyword since it existed and there was
|
||||
// never anywhere to type one. This sheet is that place, and it sits beside
|
||||
// the filing sheet above because the two are the same gesture applied to
|
||||
// two different kinds of label — pick the photographs, then say what they
|
||||
// are — and a user who has learnt one should not have to learn the other.
|
||||
//
|
||||
// Assign and unassign travel by **name**, not by id. A word typed into the
|
||||
// field and a word tapped in the list are then one path through Rust rather
|
||||
// than two, and the sheet does not have to invent an id for a keyword that
|
||||
// does not exist yet.
|
||||
/// The vocabulary, already answered against the current selection.
|
||||
in property <[KeywordRow]> keywords;
|
||||
/// The sheet is opening: Rust answers by refreshing `keywords` against
|
||||
/// whatever is selected *now*.
|
||||
///
|
||||
/// Pulled on open rather than pushed on every selection change, because the
|
||||
/// selection changes on every arrow key and the sheet is shut for almost
|
||||
/// all of them — recomputing coverage over a forty-image selection for a
|
||||
/// panel nobody is looking at is work the grid cannot afford.
|
||||
callback keywords-opened();
|
||||
callback assign-keyword(string);
|
||||
callback unassign-keyword(string);
|
||||
/// Whether the sheet is up. Local, for the same reason `filing` is: it is a
|
||||
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||||
property <bool> keywording: false;
|
||||
|
||||
// Cell geometry. Columns are derived from the available width so the grid
|
||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
||||
@@ -1355,6 +1423,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
add-keyword => {
|
||||
// Ask for the vocabulary before showing the sheet, so
|
||||
// it is answered against the selection as it stands now
|
||||
// rather than as it stood when the grid last loaded.
|
||||
root.keywords-opened();
|
||||
root.keywording = true;
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -1429,6 +1505,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// to is still there when the sheet closes.
|
||||
root.actions-open = false;
|
||||
}
|
||||
add-keyword => {
|
||||
// Ask for the vocabulary before showing the sheet, so
|
||||
// it is answered against the selection as it stands now
|
||||
// rather than as it stood when the grid last loaded.
|
||||
root.keywords-opened();
|
||||
root.keywording = true;
|
||||
root.actions-open = false;
|
||||
}
|
||||
change-library => { root.change-library(); }
|
||||
toggle-pin-scope => { root.toggle-pin-scope(); }
|
||||
sync-now => { root.sync-now(); }
|
||||
@@ -1788,6 +1872,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
// button, and a sheet it walked straight past would leave
|
||||
// the user out of the grid with their selection gone.
|
||||
if (event.text == Key.Back || event.text == Key.Escape) {
|
||||
if (root.keywording) {
|
||||
root.keywording = false;
|
||||
return accept;
|
||||
}
|
||||
if (root.filing) {
|
||||
root.filing = false;
|
||||
return accept;
|
||||
@@ -2486,4 +2574,184 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the keywording sheet (FR-CAT-5, FR-CAT-6) --------------------------
|
||||
//
|
||||
// "These are of…". Deliberately the same card, scrim and dismissal as the
|
||||
// filing sheet above: a user who has filed a selection already knows how
|
||||
// this works, and a second idiom for the same gesture would be a second
|
||||
// thing to learn for no gain.
|
||||
//
|
||||
// It stays open after each word, where the filing sheet closes. Filing is
|
||||
// one choice; keywording is usually several — "puffin", "Látrabjarg",
|
||||
// "2026" — and a sheet that shut after each one would have to be reopened,
|
||||
// and the selection re-confirmed, three times over.
|
||||
if root.keywording: Rectangle {
|
||||
background: #000000CC;
|
||||
|
||||
// Swallows the taps that miss the card, and closes. First, so the
|
||||
// card's own controls sit above it.
|
||||
TouchArea {
|
||||
clicked => { root.keywording = false; }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: min(420px, parent.width - 2 * Theme.gap-lg);
|
||||
height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
// Stops a press on the card reaching the scrim behind it.
|
||||
TouchArea { }
|
||||
|
||||
kw-sheet := VerticalLayout {
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.selected-count == 1
|
||||
? "Keywords for 1 photograph"
|
||||
: "Keywords for " + root.selected-count + " photographs";
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-lg;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Typing a word applies it, whether or not it already exists.
|
||||
// One field for both, because "is this keyword new?" is a
|
||||
// question about the catalog and not about what the user meant,
|
||||
// and Rust can answer it without being asked.
|
||||
//
|
||||
// The field clears itself on accept so the next word can be
|
||||
// typed straight after — keywording a shoot is a run of them.
|
||||
new-keyword := Field {
|
||||
placeholder: "Type a keyword and press return";
|
||||
accepted(text) => {
|
||||
root.assign-keyword(text);
|
||||
self.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
// A floor, so the list is not squeezed out of existence by
|
||||
// the field and the button around it on a short window.
|
||||
min-height: 120px;
|
||||
viewport-height: root.keywords.length * (Theme.touch-target + 2px);
|
||||
|
||||
for word[i] in root.keywords: Rectangle {
|
||||
y: i * (Theme.touch-target + 2px);
|
||||
width: parent.width;
|
||||
// A full touch target per row, for the same reason the
|
||||
// filing sheet uses one: this is a place to hit once,
|
||||
// with a thumb, holding a selection that took a minute
|
||||
// to build (FR-UI-3).
|
||||
height: Theme.touch-target;
|
||||
background: kw-touch.pressed ? Theme.pressed
|
||||
: (kw-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
// Tick, dash, or nothing — the three states of
|
||||
// `coverage`, drawn as three different marks rather
|
||||
// than as two. A half-applied keyword shown as
|
||||
// applied is a lie about photographs the user
|
||||
// cannot see from here.
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
height: 16px;
|
||||
border-radius: Theme.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: word.coverage == 0 ? Theme.rule : Theme.active;
|
||||
background: word.coverage == 2 ? Theme.active : transparent;
|
||||
|
||||
// The dash for "some of them". A bar rather
|
||||
// than a tick, because a tick at half strength
|
||||
// reads as a rendering artefact.
|
||||
if word.coverage == 1: Rectangle {
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.active;
|
||||
}
|
||||
if word.coverage == 2: Icon {
|
||||
name: "check";
|
||||
ink: Theme.surface;
|
||||
size: 12px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: word.name;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
// "3 of 12" only where it says something the mark
|
||||
// does not. For a word the whole selection carries,
|
||||
// or none of it, the mark has already said it and
|
||||
// the number would be noise on every row.
|
||||
Text {
|
||||
text: word.coverage == 1
|
||||
? word.selected-count + " of " + root.selected-count
|
||||
: (word.image-count > 0 ? word.image-count + "" : "");
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
// One target for both directions. A word the selection
|
||||
// fully carries comes off; anything else goes on — so a
|
||||
// partly-applied keyword is completed rather than
|
||||
// removed, which is what a user tapping a dash means
|
||||
// nine times in ten, and the tenth is one more tap
|
||||
// away.
|
||||
kw-touch := TouchArea {
|
||||
clicked => {
|
||||
if (word.coverage == 2) {
|
||||
root.unassign-keyword(word.name);
|
||||
} else {
|
||||
root.assign-keyword(word.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.keywords.length == 0: Text {
|
||||
text: "No keywords yet. Type one above to make the first.";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
width: parent.width;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
Button {
|
||||
text: "Done";
|
||||
clicked => { root.keywording = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user