Sync with integration

This commit is contained in:
2026-08-22 19:04:13 +02:00
22 changed files with 4534 additions and 318 deletions
+352 -46
View File
@@ -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°
+13
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
}
}
}
}
}