A preset could not choose a film stock. The stock is a choice of material rather than a parameter, so `Preset` — a map of `op.param = value` — had nowhere to hold it, and "Portra 400, printed" could not be saved, copied or shipped as a look. Worse, the film node's own sliders *were* parameters: a paste moved one stock's exposure and push onto whatever stock the target was on, and left the target's tables baked from the values it had just replaced. A preset now carries a `FilmRef` beside its parameters. It travels under whichever scope carries the film node, so the stock and its sliders are never split, and by the replacement rule every other parameter follows: applied at that scope, a preset without a film develops the target without one. `Preset::apply` returns the `FilmRebake` it owes, as `EditGraph::set_state` already did, because this crate cannot bake a stock; the develop session pays it before recording the step, and the batch paste writes the stock into each sidecar through `film_for`. The library file spells it `film =` / `film_print =`, as a sidecar does, and an older build keeps those lines as ones it does not understand. `EditState` keeps the film in its own field only: the parameters it captures leave it out, so one edit has one place to say which stock it is on. Second, a preset now has a reach. Replacement is right for a copy of a whole edit — "make these match" — and wrong for a look: a stock-only "Portra 400" applied that way would put the photograph's exposure, white balance and noise reduction back to default. `Reach::Named` replaces only the operations a preset names (whole operations, so a look that sets the blacks resets the whites beside them) and the film only if it names one. Saved edits and the clipboard keep `Reach::Whole`; the line `reach = named` is written only for the other, so existing libraries write the same bytes.
1450 lines
54 KiB
Rust
1450 lines
54 KiB
Rust
//! Ratings, flags, bursts and keywords: applying a judgement to a selection,
|
||
//! keeping the grid and the filter chips honest about what landed, and the
|
||
//! sidecar and XMP writes a judgement or a paste of develop settings queues
|
||
//! behind it.
|
||
//!
|
||
//! `dr_catalog::rating` and `dr_catalog::keywords` own the data rules; this
|
||
//! owns which photographs a keystroke or a sheet acts on, and reporting what
|
||
//! actually changed rather than what was asked for. See
|
||
//! `docs/dev/code-health.md` CH-1.
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use dr_catalog::Catalog;
|
||
use slint::{ComponentHandle, Model as _};
|
||
|
||
use crate::library;
|
||
use crate::{AppWindow, KeywordRow, Library};
|
||
|
||
use super::controller::{stop, LibraryController};
|
||
use super::window::load_window;
|
||
|
||
/// Push each visible image's stars and flag into the grid model.
|
||
///
|
||
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
|
||
/// cells is 120 round trips otherwise, on every scroll and after every
|
||
/// keystroke.
|
||
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
|
||
Ok(j) => j,
|
||
Err(e) => {
|
||
// The grid is still usable without stars, so this is logged rather
|
||
// than surfaced — a failure here must not blank the library.
|
||
log::debug!("reading ratings: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
let model = window.global::<Library>().get_library_cells();
|
||
for (row, id) in ids.iter().enumerate() {
|
||
// Absent means unrated, which is a real state rather than missing data.
|
||
let j = found.get(id).copied().unwrap_or_default();
|
||
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
|
||
let label = j
|
||
.label
|
||
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32);
|
||
|
||
if let Some(mut cell) = model.row_data(row) {
|
||
if cell.rating != rating || cell.flag != flag || cell.label != label {
|
||
cell.rating = rating;
|
||
cell.flag = flag;
|
||
cell.label = label;
|
||
model.set_row_data(row, cell);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Refresh the filter chips' per-star counts.
|
||
///
|
||
/// Whole-library figures, deliberately: they say what narrowing to a filter
|
||
/// would show, so computing them over the current window would make each chip
|
||
/// describe the view it is meant to change.
|
||
pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
|
||
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
|
||
window
|
||
.global::<Library>()
|
||
.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
|
||
|
||
// TRACES: FR-CAT-9
|
||
// How many originals are actually here, for the "On this device" chip.
|
||
// Shown for the same reason the star counts are: a filter that silently
|
||
// empties the grid reads as broken, and this one will legitimately be zero
|
||
// on a library nothing has been downloaded from yet.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-6
|
||
/// Refresh the label chips' counts. Separate from the star counts because
|
||
/// the two change on different events: a star key cannot move a label count,
|
||
/// so a cull does not pay for this read on every keystroke.
|
||
pub(super) fn refresh_label_counts(window: &AppWindow, catalog: &Catalog) {
|
||
let counts = dr_catalog::rating::label_histogram(catalog.connection()).unwrap_or_default();
|
||
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
|
||
window
|
||
.global::<Library>()
|
||
.set_library_label_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5
|
||
/// What a label gesture asks for: a key toggles, the picker names the answer.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum LabelGesture {
|
||
Toggle(dr_types::ColourLabel),
|
||
Set(Option<dr_types::ColourLabel>),
|
||
}
|
||
|
||
impl LabelGesture {
|
||
/// From Slint's code and whether it came from a key. A code with no
|
||
/// colour is "none" for the picker, and nothing at all for a key.
|
||
fn from_code(code: i32, toggle: bool) -> Option<Self> {
|
||
let label = dr_catalog::rating::label_from_code(Some(code as i64));
|
||
match (toggle, label) {
|
||
(true, Some(l)) => Some(Self::Toggle(l)),
|
||
(true, None) => None,
|
||
(false, l) => Some(Self::Set(l)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-5 | FR-CAT-13
|
||
/// Label a set of images: catalog first, in one transaction, then the grid,
|
||
/// the chips and both sidecars — the order and the reasons of
|
||
/// [`apply_judgement`].
|
||
fn apply_label(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
gesture: LabelGesture,
|
||
) {
|
||
if images.is_empty() {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
let label = match gesture {
|
||
LabelGesture::Set(l) => l,
|
||
// One read of what they carry now, for the whole set.
|
||
LabelGesture::Toggle(pressed) => {
|
||
let now = dr_catalog::rating::judgements(conn, images).unwrap_or_default();
|
||
dr_catalog::rating::toggled_label(
|
||
images.iter().map(|id| now.get(id).and_then(|j| j.label)),
|
||
pressed,
|
||
)
|
||
}
|
||
};
|
||
if let Err(e) = dr_catalog::rating::set_label_many(conn, images, label) {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_error(format!("recording label: {e}").into());
|
||
return;
|
||
}
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status(label_summary(images.len(), label).into());
|
||
|
||
let visible = ctl.visible_ids();
|
||
sync_ratings(window, catalog, &visible);
|
||
refresh_label_counts(window, catalog);
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
|
||
// A grid narrowed to red no longer holds a frame just made green.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
start_sidecar_writes(window, ctl, writes);
|
||
start_xmp_writes(window, ctl, images);
|
||
}
|
||
|
||
/// What the status line says about a label that just landed — in words, as
|
||
/// every other judgement is reported.
|
||
fn label_summary(n: usize, label: Option<dr_types::ColourLabel>) -> String {
|
||
let what = match label {
|
||
Some(l) => format!("{} label", label_name(l).to_lowercase()),
|
||
None => "no label".to_string(),
|
||
};
|
||
if n == 1 {
|
||
what[..1].to_uppercase() + &what[1..]
|
||
} else {
|
||
format!("{n} images · {what}")
|
||
}
|
||
}
|
||
|
||
/// A label's name, as the chips and the picker spell it.
|
||
fn label_name(l: dr_types::ColourLabel) -> &'static str {
|
||
match l {
|
||
dr_types::ColourLabel::Red => "Red",
|
||
dr_types::ColourLabel::Yellow => "Yellow",
|
||
dr_types::ColourLabel::Green => "Green",
|
||
dr_types::ColourLabel::Blue => "Blue",
|
||
dr_types::ColourLabel::Purple => "Purple",
|
||
}
|
||
}
|
||
|
||
// --- 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
|
||
.global::<Library>()
|
||
.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
|
||
.global::<Library>()
|
||
.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
|
||
.global::<Library>()
|
||
.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
|
||
.global::<Library>()
|
||
.set_library_error(format!("{e}").into());
|
||
return;
|
||
}
|
||
};
|
||
|
||
window
|
||
.global::<Library>()
|
||
.set_library_error(slint::SharedString::new());
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||
refresh_keywords(window, ctl, &images);
|
||
start_xmp_writes(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
|
||
///
|
||
/// The catalog is written **synchronously and first**, so the star appears
|
||
/// immediately and survives a restart even if the network is down. The sidecar
|
||
/// write is queued behind it on a worker thread — it is what makes the
|
||
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
|
||
/// rarer concern than the user seeing their keystroke take effect.
|
||
///
|
||
/// Doing it the other way round would mean a cull that stalls on every
|
||
/// keypress waiting for a round trip, on a workflow whose entire premise is
|
||
/// speed (FR-CULL-1).
|
||
fn apply_judgement(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
rating: Option<u8>,
|
||
flag: Option<dr_types::FlagState>,
|
||
) {
|
||
if images.is_empty() {
|
||
// Nothing selected. Said out loud rather than ignored: a keystroke
|
||
// that silently does nothing reads as a broken key.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let conn = catalog.connection();
|
||
|
||
let wrote = match (rating, flag) {
|
||
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
|
||
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
|
||
// Neither axis named: nothing to do, and not an error.
|
||
(None, None) => return,
|
||
};
|
||
|
||
if let Err(e) = wrote {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_error(format!("recording rating: {e}").into());
|
||
return;
|
||
}
|
||
|
||
// Report what happened, in the user's terms rather than as a count of
|
||
// rows. A bulk judgement on a selection is easy to trigger by accident
|
||
// and the status line is the only confirmation of its extent.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status(judgement_summary(images.len(), rating, flag).into());
|
||
|
||
// Refresh the grid and the chips from what actually landed, rather
|
||
// than assuming the write took: a clamped or coalesced value must show
|
||
// as what is stored.
|
||
let visible = ctl.visible_ids();
|
||
sync_ratings(window, catalog, &visible);
|
||
refresh_rating_counts(window, catalog);
|
||
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
|
||
// A filtered grid may no longer contain what was just judged — rating an
|
||
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
|
||
// keeps the cells and the header count honest.
|
||
if !ctl.filter.borrow().is_unfiltered() {
|
||
load_window(window, ctl);
|
||
}
|
||
|
||
start_sidecar_writes(window, ctl, writes);
|
||
start_xmp_writes(window, ctl, images);
|
||
}
|
||
|
||
/// TRACES: FR-UI-5 | FR-CULL-4
|
||
/// The photograph at one row of the loaded window, for the gestures that
|
||
/// name a single photograph: a star clicked on a cell, and develop's keys
|
||
/// and top bar, which name the open one by its roll row.
|
||
///
|
||
/// `None` for a negative row as well as one past the end. Develop passes
|
||
/// `-1` when the open photograph is not in the window, and the answer then
|
||
/// is to judge nothing — never to fall back on the grid's selection, which
|
||
/// is not what is on screen.
|
||
fn image_at_row(ids: &[i64], row: i32) -> Option<dr_types::ImageId> {
|
||
let row = usize::try_from(row).ok()?;
|
||
ids.get(row).map(|id| dr_types::ImageId(*id as u64))
|
||
}
|
||
|
||
/// What the status line says about a judgement that just landed.
|
||
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
|
||
let what = match (rating, flag) {
|
||
(Some(0), _) => "unrated".to_string(),
|
||
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
|
||
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
|
||
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
|
||
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
|
||
(None, None) => return String::new(),
|
||
};
|
||
if n == 1 {
|
||
what
|
||
} else {
|
||
format!("{n} images · {what}")
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Apply copied develop settings to a selection of images.
|
||
///
|
||
/// # Why this goes straight to the sidecars
|
||
///
|
||
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
|
||
/// catalog holds no parameters at all — only a `graph_hash` — so there is
|
||
/// nothing here for the catalog to record. Nor is any image opened: applying
|
||
/// to forty frames by loading forty develop sessions would mean forty RAW
|
||
/// downloads and forty demosaics to move some numbers between two maps, which
|
||
/// is not a thing to ask of a phone. See [`crate::presets`].
|
||
///
|
||
/// # What the user sees
|
||
///
|
||
/// Nothing in the grid changes — a thumbnail is rendered from the server's
|
||
/// preview and does not reflect an edit — so the status line is the only
|
||
/// confirmation, exactly as it is for a bulk judgement. The applied settings
|
||
/// appear when a target is next opened in develop, which is what reads the
|
||
/// sidecar back.
|
||
pub fn paste_settings_to_selection(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) {
|
||
if images.is_empty() {
|
||
// Said out loud rather than ignored, matching what a judgement
|
||
// keystroke does with an empty selection.
|
||
window
|
||
.global::<Library>()
|
||
.set_library_status("Select an image first".into());
|
||
return;
|
||
}
|
||
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
// A never-judged image may have no version row yet, and the query
|
||
// below joins on one. Ratings create them as a side effect; a paste
|
||
// is the first write path that can reach an image which has never
|
||
// been rated, so it has to ask for them itself.
|
||
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
|
||
log::debug!("ensuring versions before a paste: {e}");
|
||
}
|
||
collect_settings_writes(catalog, images, preset, scope)
|
||
};
|
||
|
||
if writes.is_empty() {
|
||
window
|
||
.global::<Library>()
|
||
.set_library_error("Could not find those images in the catalog.".into());
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
window.global::<Library>().set_library_status(
|
||
format!(
|
||
"Applied settings to {count} image{}.",
|
||
if count == 1 { "" } else { "s" }
|
||
)
|
||
.into(),
|
||
);
|
||
start_sidecar_writes(window, ctl, writes);
|
||
}
|
||
|
||
/// Gather one settings write per image, addressed by remote path and version.
|
||
///
|
||
/// The uuid comes from the catalog for the same reason a judgement's does: it
|
||
/// is the identity a cross-device merge keys on, and a generated one would
|
||
/// write a second version beside the one the image already has (FR-NC-8).
|
||
fn collect_settings_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
preset: &dr_pipeline::Preset,
|
||
scope: dr_pipeline::Scope,
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Settings {
|
||
preset: preset.clone(),
|
||
scope,
|
||
// A paste carries no masks, and must not: a mask is drawn
|
||
// against one photograph and describes nothing on another.
|
||
// The target keeps whatever local adjustments it already had.
|
||
masks: None,
|
||
// TRACES: FR-DEV-3f
|
||
// The film travels where its sliders do: a stock is a look,
|
||
// not a fact about one frame, and a preset carrying one is
|
||
// how "Portra, printed" is applied to forty photographs.
|
||
film: preset.film_for(scope).map(|f| f.cloned()),
|
||
// Nor snapshots: they are states of the photograph they were
|
||
// taken on, and mean nothing on another.
|
||
snapshots: None,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting settings writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Gather what the sidecar writer needs for each judged image.
|
||
///
|
||
/// The version uuid comes from the catalog rather than being generated here:
|
||
/// it is the identity a cross-device merge keys on, so the sidecar and the
|
||
/// catalog must name the same version or a sync would treat one photograph's
|
||
/// judgement as two (FR-NC-8).
|
||
fn collect_sidecar_writes(
|
||
catalog: &Catalog,
|
||
images: &[dr_types::ImageId],
|
||
) -> Vec<library::SidecarWrite> {
|
||
let placeholders = std::iter::repeat_n("?", images.len())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let sql = format!(
|
||
"SELECT i.source_ref, v.uuid, v.rating, v.flag, coalesce(v.label, 0)
|
||
FROM images i
|
||
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
|
||
WHERE i.id IN ({placeholders})"
|
||
);
|
||
let params: Vec<rusqlite::types::Value> = images
|
||
.iter()
|
||
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
||
.collect();
|
||
|
||
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
||
return Vec::new();
|
||
};
|
||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||
Ok(library::SidecarWrite {
|
||
image_path: r.get(0)?,
|
||
version_uuid: r.get(1)?,
|
||
amendment: library::Amendment::Judgement {
|
||
rating: r.get::<_, i64>(2)? as u8,
|
||
flag: r.get::<_, i64>(3)? as u8,
|
||
label: r.get::<_, i64>(4)?.clamp(0, 5) as u8,
|
||
},
|
||
})
|
||
});
|
||
|
||
match rows {
|
||
Ok(rows) => rows.flatten().collect(),
|
||
Err(e) => {
|
||
log::debug!("collecting sidecar writes: {e}");
|
||
Vec::new()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-11a | FR-CAT-13
|
||
/// Write these images' judgements to their sidecars and, where switched on,
|
||
/// their `.xmp` files, and bring the chips up to date — what a rating
|
||
/// keystroke does after its catalog write, for a change that did not come
|
||
/// from a keystroke. Consolidating duplicates merges ratings, flags, labels
|
||
/// and keywords onto the copy that stays, and this is how that reaches the
|
||
/// file beside it.
|
||
pub(crate) fn save_judgements(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
) {
|
||
if images.is_empty() {
|
||
return;
|
||
}
|
||
let writes = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
refresh_rating_counts(window, catalog);
|
||
refresh_label_counts(window, catalog);
|
||
collect_sidecar_writes(catalog, images)
|
||
};
|
||
start_sidecar_writes(window, ctl, writes);
|
||
start_xmp_writes(window, ctl, images);
|
||
}
|
||
|
||
/// Push judgements out to sidecars on a worker, reporting once at the end.
|
||
/// TRACES: FR-CAT-13 | NFR-R4
|
||
/// Write these images' ratings, labels and keywords to the `.xmp` beside
|
||
/// each, where the user has switched that on.
|
||
///
|
||
/// The record is read from the catalog *now*, whole, rather than carried
|
||
/// from the gesture: a rating and a keyword typed a second apart are two
|
||
/// writes of the same file, and the second must not carry a copy of the
|
||
/// first taken before it landed.
|
||
pub(crate) fn start_xmp_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
images: &[dr_types::ImageId],
|
||
) {
|
||
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
|
||
return;
|
||
}
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let writes: Vec<library::XmpWrite> = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let c = catalog.connection();
|
||
images
|
||
.iter()
|
||
.filter_map(|&image| {
|
||
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
|
||
let image_path: String = c
|
||
.query_row(
|
||
"SELECT source_ref FROM images WHERE id = ?1",
|
||
[image.0 as i64],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()?;
|
||
Some(library::XmpWrite {
|
||
image_path,
|
||
record: crate::xmp_sync::record_of(c, image, version),
|
||
})
|
||
})
|
||
.collect()
|
||
};
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let count = writes.len();
|
||
let rx = library::spawn_xmp_writes(conn, writes);
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Upload,
|
||
format!("Writing {count} XMP sidecar(s)"),
|
||
);
|
||
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-13
|
||
/// The offered reload: take the sidecars' values for every photograph the
|
||
/// last pull found disagreeing with the catalog.
|
||
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let paths: Vec<String> = {
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
|
||
return;
|
||
};
|
||
crate::xmp_sync::conflicts(catalog, root_id)
|
||
.into_iter()
|
||
.map(|c| c.path)
|
||
.collect()
|
||
};
|
||
if paths.is_empty() {
|
||
return;
|
||
}
|
||
let count = paths.len();
|
||
let rx = library::spawn_xmp_reload(
|
||
conn.clone(),
|
||
conn.account.root.clone(),
|
||
library::catalog_path(&conn.account),
|
||
paths,
|
||
);
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Download,
|
||
format!("Reloading {count} XMP sidecar(s)"),
|
||
);
|
||
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
|
||
}
|
||
|
||
/// Wait for a batch of XMP work to report, then say what it did.
|
||
fn drain_xmp(
|
||
weak: slint::Weak<AppWindow>,
|
||
ctl: Rc<LibraryController>,
|
||
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
|
||
job: crate::activity::Activity,
|
||
reload: bool,
|
||
) {
|
||
let timer = slint::Timer::default();
|
||
let job = RefCell::new(Some(job));
|
||
let ctl_cb = ctl.clone();
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let ctl = &ctl_cb;
|
||
let message = match rx.try_recv() {
|
||
Ok(m) => m,
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&ctl.xmp_timer);
|
||
return;
|
||
}
|
||
};
|
||
stop(&ctl.xmp_timer);
|
||
let library::XmpMessage::Finished {
|
||
written,
|
||
failed,
|
||
last_error,
|
||
} = message;
|
||
let status = match (reload, failed, last_error) {
|
||
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
|
||
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
|
||
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
|
||
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
|
||
};
|
||
if let Some(job) = job.borrow_mut().take() {
|
||
if failed > 0 {
|
||
job.fail(status.clone());
|
||
} else {
|
||
job.finish(status.clone());
|
||
}
|
||
}
|
||
if let Some(w) = weak.upgrade() {
|
||
w.global::<Library>().set_library_status(status.into());
|
||
if reload {
|
||
// The grid draws what the reload changed, and the
|
||
// settings page stops offering what is settled.
|
||
let visible = ctl.visible_ids();
|
||
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
|
||
sync_ratings(&w, catalog, &visible);
|
||
refresh_rating_counts(&w, catalog);
|
||
refresh_label_counts(&w, catalog);
|
||
}
|
||
refresh_xmp_conflicts(&w, ctl);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
*ctl.xmp_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// TRACES: FR-CAT-13
|
||
/// How many sidecars the last pull found disagreeing with the catalog, for
|
||
/// the settings page to offer the reload against.
|
||
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||
let count = (|| {
|
||
let (conn, _) = ctl.session.borrow().clone()?;
|
||
let borrow = ctl.catalog.borrow();
|
||
let catalog = borrow.as_ref()?;
|
||
let root_id = root_id_of(catalog, &conn.account.root)?;
|
||
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
|
||
})()
|
||
.unwrap_or(0);
|
||
window.set_settings_xmp_conflicts(count as i32);
|
||
}
|
||
|
||
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
|
||
catalog
|
||
.connection()
|
||
.query_row(
|
||
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
|
||
[root],
|
||
|r| r.get(0),
|
||
)
|
||
.ok()
|
||
}
|
||
|
||
pub(crate) fn start_sidecar_writes(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
writes: Vec<library::SidecarWrite>,
|
||
) {
|
||
if writes.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// TRACES: FR-CAT-9
|
||
// Offline is passed down rather than used to skip.
|
||
//
|
||
// It used to skip, and the reasoning was that a cull stays responsive
|
||
// because the rating is safe in the catalog. That held for judgements and
|
||
// not for edits: the catalog stores no parameters, so a pasted edit made
|
||
// offline survived nowhere at all. Every write now commits to the local
|
||
// sidecar cache first and the upload is best-effort, which keeps the
|
||
// keystroke path off the network — the original concern — without the
|
||
// write being conditional on it.
|
||
let offline = ctl.is_offline();
|
||
|
||
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
||
return;
|
||
};
|
||
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||
return;
|
||
};
|
||
|
||
let count = writes.len();
|
||
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
|
||
|
||
let timer = slint::Timer::default();
|
||
let weak = window.as_weak();
|
||
let ctl_cb = ctl.clone();
|
||
|
||
// The writer reports once at the end, so there is no per-file progress to
|
||
// show — but a cull that has just rated forty frames has forty uploads in
|
||
// flight, and "is that saved yet" deserves an answer somewhere.
|
||
let job = ctl.activity.begin(
|
||
crate::activity::Kind::Upload,
|
||
format!("Saving {count} judgement(s)"),
|
||
);
|
||
|
||
timer.start(
|
||
slint::TimerMode::Repeated,
|
||
std::time::Duration::from_millis(200),
|
||
move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// One message is all this channel ever carries — the writer reports
|
||
// `Finished` once and hangs up — so this drains a single item rather
|
||
// than looping like the scan and thumbnail drains do.
|
||
match rx.try_recv() {
|
||
Ok(library::SidecarMessage::Finished {
|
||
written,
|
||
queued,
|
||
failed,
|
||
last_error,
|
||
}) => {
|
||
if failed == 0 && queued > 0 {
|
||
// Recorded locally, waiting for the server. Said out
|
||
// loud because the user has just made an edit with no
|
||
// connection and deserves to know it is safe — the
|
||
// old behaviour here was to drop it silently.
|
||
log::debug!("{queued} sidecar(s) queued for upload");
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
job.finish_quietly();
|
||
w.global::<Library>().set_library_status(
|
||
format!("{queued} edit(s) saved · will upload when back online").into(),
|
||
);
|
||
stop(&ctl_cb.sidecar_timer);
|
||
return;
|
||
}
|
||
if failed > 0 {
|
||
// A failed upload left the edit in the outbox.
|
||
ctl_cb.outbox_maybe_dirty.set(true);
|
||
log::warn!(
|
||
"{failed} sidecar write(s) failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
);
|
||
job.fail(format!(
|
||
"{written} saved · {failed} failed: {}",
|
||
last_error.clone().unwrap_or_default()
|
||
));
|
||
// Said plainly, because the consequence is specific:
|
||
// the rating is safe in the catalog but will not
|
||
// survive deleting it.
|
||
w.global::<Library>().set_library_status(
|
||
format!(
|
||
"{written} saved · {failed} could not be written \
|
||
to the library folder"
|
||
)
|
||
.into(),
|
||
);
|
||
} else {
|
||
log::debug!("{written} sidecar(s) written");
|
||
// Quietly: a cull produces one of these every few
|
||
// seconds and none of them is news.
|
||
job.finish_quietly();
|
||
}
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
stop(&ctl_cb.sidecar_timer);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
|
||
*ctl.sidecar_timer.borrow_mut() = Some(timer);
|
||
}
|
||
|
||
/// Slint carries the flag as an integer, matching the catalog's encoding.
|
||
fn flag_code(f: dr_types::FlagState) -> i32 {
|
||
match f {
|
||
dr_types::FlagState::Unflagged => 0,
|
||
dr_types::FlagState::Pick => 1,
|
||
dr_types::FlagState::Reject => 2,
|
||
}
|
||
}
|
||
|
||
/// The flag an integer from Slint stands for.
|
||
fn flag_from_code(v: i32) -> dr_types::FlagState {
|
||
match v {
|
||
1 => dr_types::FlagState::Pick,
|
||
2 => dr_types::FlagState::Reject,
|
||
_ => dr_types::FlagState::Unflagged,
|
||
}
|
||
}
|
||
|
||
/// --- ratings and flags (FR-CAT-5, FR-CULL-4) --------------------------
|
||
pub(super) fn wire_ratings_and_flags(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
// Clicking a star rates *that cell*, not the selection. The pointer names
|
||
// one photograph unambiguously, and a click that silently rated forty
|
||
// others would be a trap — the keyboard is the bulk gesture.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_cell_rated(move |row, stars| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
|
||
return;
|
||
};
|
||
|
||
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-UI-5 | FR-CULL-4
|
||
// The flag of one photograph by row: develop's Pick and Reject, and its
|
||
// P, X and U. The open photograph only, for the reason a star clicked in
|
||
// the grid rates only its own cell — develop is working on one frame, and
|
||
// a selection left behind in the grid is not what is in front of it.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_cell_flagged(move |row, flag| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
|
||
return;
|
||
};
|
||
|
||
apply_judgement(&w, &ctl, &[id], None, Some(flag_from_code(flag)));
|
||
});
|
||
}
|
||
|
||
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
|
||
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_burst_toggled(move |row| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let id = ctl
|
||
.image_ids
|
||
.borrow()
|
||
.get(row as usize)
|
||
.map(|id| dr_types::ImageId(*id as u64));
|
||
let Some(id) = id else { return };
|
||
|
||
let changed = {
|
||
let borrow = ctl.catalog.borrow();
|
||
match borrow.as_ref() {
|
||
Some(catalog) => crate::bursts::toggle(catalog, id),
|
||
None => false,
|
||
}
|
||
};
|
||
if changed {
|
||
ctl.requested.borrow_mut().clear();
|
||
load_window(&w, &ctl);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Name the frame an open burst folds to (FR-CULL-5). The default is the
|
||
// earliest of them, chosen because it is a fact about the clock and not a
|
||
// judgement about the photograph; this is the photographer, who is the only
|
||
// one who knows which of the twelve is the keeper, saying otherwise.
|
||
//
|
||
// A repaint of the badges rather than a reload, which is what separates it
|
||
// from folding a burst up: the grid's query returns the same rows either
|
||
// way while the group is open, and only the mark on them moves. Refilled
|
||
// for the whole window in one statement — the tick has to leave the frame
|
||
// that was carrying it, and that frame is a different cell.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_burst_representative_chosen(move |row| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let ids: Vec<dr_types::ImageId> = ctl
|
||
.image_ids
|
||
.borrow()
|
||
.iter()
|
||
.map(|id| dr_types::ImageId(*id as u64))
|
||
.collect();
|
||
let Some(id) = ids.get(row as usize).copied() else {
|
||
return;
|
||
};
|
||
|
||
let borrow = ctl.catalog.borrow();
|
||
let Some(catalog) = borrow.as_ref() else {
|
||
return;
|
||
};
|
||
if crate::bursts::choose(catalog, id) {
|
||
crate::bursts::sync_badges(&w, catalog, &ids);
|
||
}
|
||
});
|
||
}
|
||
|
||
// A rating or flag key. Applies to the whole selection, which is what
|
||
// makes judging a run of frames one keystroke rather than forty — unless
|
||
// the pointer is over a photograph outside it, which then takes the key
|
||
// alone (darktable's rule; see `judged_images`).
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keys = coll_ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_judged(move |rating, flag, hovered| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
|
||
let under_pointer = usize::try_from(hovered)
|
||
.ok()
|
||
.and_then(|ordinal| ordinal.checked_sub(offset))
|
||
.and_then(|row| ctl.visible_ids().get(row).copied());
|
||
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
|
||
|
||
// Exactly one axis is meant per keystroke; the other arrives as
|
||
// -1 so a star press cannot disturb a flag or the reverse.
|
||
if rating >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
|
||
} else if flag >= 0 {
|
||
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
|
||
}
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | FR-UI-5
|
||
// A label key in the grid: the photographs a rating key would reach,
|
||
// toggled as one set.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keys = coll_ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_labelled(move |code, hovered| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(gesture) = LabelGesture::from_code(code, true) else {
|
||
return;
|
||
};
|
||
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
|
||
let under_pointer = usize::try_from(hovered)
|
||
.ok()
|
||
.and_then(|ordinal| ordinal.checked_sub(offset))
|
||
.and_then(|row| ctl.visible_ids().get(row).copied());
|
||
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
|
||
apply_label(&w, &ctl, &chosen, gesture);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-5 | FR-UI-4
|
||
// The selection bar's picker: the selection, set outright.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll = coll_ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_label_chosen(move |code| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(gesture) = LabelGesture::from_code(code, false) else {
|
||
return;
|
||
};
|
||
apply_label(&w, &ctl, &coll.selected(), gesture);
|
||
});
|
||
}
|
||
|
||
// TRACES: FR-CAT-5
|
||
// One photograph by row — the one open in develop.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.on_library_cell_labelled(move |row, code, toggle| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(gesture) = LabelGesture::from_code(code, toggle) else {
|
||
return;
|
||
};
|
||
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
|
||
return;
|
||
};
|
||
apply_label(&w, &ctl, &[id], gesture);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-5
|
||
/// What a judgement key applies to: the photograph under the pointer when it
|
||
/// is not part of the selection, and the selection otherwise.
|
||
///
|
||
/// Hovering one of the selected frames rates all of them, so the pointer
|
||
/// resting on the grid never shrinks a forty-frame judgement to one. With
|
||
/// the pointer off the grid, or over a cell the grid has since scrolled away
|
||
/// from, it is the selection as it always was.
|
||
fn judged_images(
|
||
selection: Vec<dr_types::ImageId>,
|
||
under_pointer: Option<dr_types::ImageId>,
|
||
) -> Vec<dr_types::ImageId> {
|
||
match under_pointer {
|
||
Some(id) if !selection.contains(&id) => vec![id],
|
||
_ => selection,
|
||
}
|
||
}
|
||
|
||
/// --- 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.
|
||
pub(super) fn wire_keywords(
|
||
window: &AppWindow,
|
||
ctl: &Rc<LibraryController>,
|
||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||
) {
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let coll_for_keywords = coll_ctl.clone();
|
||
window
|
||
.global::<Library>()
|
||
.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
|
||
.global::<Library>()
|
||
.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
|
||
.global::<Library>()
|
||
.on_library_unassign_keyword(move |word| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
apply_keyword(&w, &ctl, word.as_str(), false);
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// --- judging the photograph open in develop (FR-UI-5) -----------------
|
||
|
||
/// TRACES: FR-UI-5
|
||
#[test]
|
||
fn a_row_names_one_photograph_and_no_row_names_none() {
|
||
let ids = [11, 22, 33];
|
||
assert_eq!(image_at_row(&ids, 1), Some(dr_types::ImageId(22)));
|
||
assert_eq!(image_at_row(&ids, 3), None);
|
||
// The open photograph outside the loaded window: nothing is judged,
|
||
// rather than whatever the grid last had selected.
|
||
assert_eq!(image_at_row(&ids, -1), None);
|
||
assert_eq!(image_at_row(&[], 0), None);
|
||
}
|
||
|
||
/// The `if (Keys.chord(event) == "<key>") { … }` line of develop's
|
||
/// key handler that binds `key`.
|
||
fn develop_binding(key: &str) -> &'static str {
|
||
let app = include_str!("../../ui/app.slint");
|
||
let scope = &app[app
|
||
.find("// KEYMAP: Develop\n key-pressed(event)")
|
||
.expect("app.slint no longer has develop's key-pressed handler")..];
|
||
let needle = format!("if (Keys.chord(event) == \"{key}\") {{");
|
||
let at = scope
|
||
.find(&needle)
|
||
.unwrap_or_else(|| panic!("develop does not bind `{key}`"));
|
||
scope[at..].lines().next().unwrap_or("")
|
||
}
|
||
|
||
/// TRACES: FR-UI-5 | FR-CULL-4
|
||
/// 0–5, P, X and U in develop judge the open photograph — its roll row —
|
||
/// and do nothing else: no step to the next frame, which belongs to
|
||
/// culling's auto-advance and not to the view where one frame is worked on.
|
||
#[test]
|
||
fn develop_judges_the_open_photograph_and_stays_on_it() {
|
||
for (key, call) in [
|
||
("0", "library-cell-rated(Library.library-roll-current, 0)"),
|
||
("1", "library-cell-rated(Library.library-roll-current, 1)"),
|
||
("2", "library-cell-rated(Library.library-roll-current, 2)"),
|
||
("3", "library-cell-rated(Library.library-roll-current, 3)"),
|
||
("4", "library-cell-rated(Library.library-roll-current, 4)"),
|
||
("5", "library-cell-rated(Library.library-roll-current, 5)"),
|
||
("P", "library-cell-flagged(Library.library-roll-current, 1)"),
|
||
("X", "library-cell-flagged(Library.library-roll-current, 2)"),
|
||
("U", "library-cell-flagged(Library.library-roll-current, 0)"),
|
||
] {
|
||
let line = develop_binding(key);
|
||
assert!(line.contains(call), "`{key}` in develop: {line}");
|
||
for step in ["step-photo", "roll-pick", "move-cursor"] {
|
||
assert!(
|
||
!line.contains(step),
|
||
"`{key}` in develop also steps: {line}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- what a judgement key reaches (FR-UI-5) ----------------------------
|
||
|
||
#[test]
|
||
fn a_hovered_photograph_outside_the_selection_is_judged_alone() {
|
||
let (a, b, c) = (
|
||
dr_types::ImageId(1),
|
||
dr_types::ImageId(2),
|
||
dr_types::ImageId(3),
|
||
);
|
||
assert_eq!(judged_images(vec![a, b], Some(c)), vec![c]);
|
||
}
|
||
|
||
#[test]
|
||
fn hovering_a_selected_photograph_judges_the_whole_selection() {
|
||
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
|
||
assert_eq!(judged_images(vec![a, b], Some(b)), vec![a, b]);
|
||
}
|
||
|
||
#[test]
|
||
fn with_nothing_hovered_the_selection_is_judged() {
|
||
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
|
||
assert_eq!(judged_images(vec![a, b], None), vec![a, b]);
|
||
assert_eq!(judged_images(vec![], None), vec![]);
|
||
}
|
||
|
||
// --- colour labels (FR-CAT-5) -----------------------------------------
|
||
|
||
#[test]
|
||
fn a_label_key_toggles_and_the_picker_sets() {
|
||
use dr_types::ColourLabel::*;
|
||
assert_eq!(
|
||
LabelGesture::from_code(1, true),
|
||
Some(LabelGesture::Toggle(Red))
|
||
);
|
||
// A key with no colour behind it does nothing rather than clearing.
|
||
assert_eq!(LabelGesture::from_code(0, true), None);
|
||
assert_eq!(
|
||
LabelGesture::from_code(0, false),
|
||
Some(LabelGesture::Set(None))
|
||
);
|
||
assert_eq!(
|
||
LabelGesture::from_code(5, false),
|
||
Some(LabelGesture::Set(Some(Purple)))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_label_is_reported_by_name() {
|
||
// The status line names the label in words, never only by its mark.
|
||
assert_eq!(
|
||
label_summary(1, Some(dr_types::ColourLabel::Green)),
|
||
"Green label"
|
||
);
|
||
assert_eq!(label_summary(3, None), "3 images · no label");
|
||
}
|
||
|
||
// --- 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"
|
||
);
|
||
}
|
||
}
|