An audit of every action by view against the keys the handlers bind left develop without zoom, pan, fit or a way back to the grid, the grid without select-none, thumbnail size or keywording, People with no key at all, and the export and copy sheets without Enter. It also found the reverse gap FR-UI-5 forbids: pick and reject had no route but P, X and U, and the 2026-09-19 amendment's judging in develop had not been built. Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid a mask. 0-5, P, X and U rate and flag the open photograph without moving on, with stars and Pick/Reject in the top bar as the pointer and touch route. = and - nudge the control last moved by a hundredth of its travel; the framing sliders, perspective included, now count as "last moved", so R puts them back as well. J turns the selected mask part's join chip. Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives pick and reject a pointer and touch route. Sidebar: Enter commits a collection's name, and Enter or Escape hands the keyboard back to the grid, where it used to go nowhere until something was clicked. People: Up and Down walk the rail, F2 puts the name field under the keys, and Escape or Back now leave the screen the way its back button does instead of doing nothing. Sheets: Enter does what the export or copy sheet's button does. The choices follow Lightroom where it has one. No new key steals typing: the grid's and People's keys live on focus holders that are not ancestors of any text field, and the sheets' Enter comes after a focused field has had it. Every binding is tagged beside its handler, and the gate added in the previous commit holds the two to each other.
1361 lines
50 KiB
Rust
1361 lines
50 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);
|
|
}
|
|
|
|
/// 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,
|
|
// A paste carries no film either, and for a plainer reason
|
|
// than the masks: a preset is a parameter map, and a stock is
|
|
// not a parameter. Pasting one would be pasting a choice the
|
|
// clipboard never captured.
|
|
film: None,
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 id = ctl
|
|
.image_ids
|
|
.borrow()
|
|
.get(row as usize)
|
|
.map(|id| dr_types::ImageId(*id as u64));
|
|
let Some(id) = id 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 id = ctl
|
|
.image_ids
|
|
.borrow()
|
|
.get(row as usize)
|
|
.map(|id| dr_types::ImageId(*id as u64));
|
|
let Some(id) = id 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 id = ctl
|
|
.image_ids
|
|
.borrow()
|
|
.get(row as usize)
|
|
.map(|id| dr_types::ImageId(*id as u64));
|
|
let Some(id) = id 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::*;
|
|
|
|
// --- 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"
|
|
);
|
|
}
|
|
}
|