Files
DarkRoom/ui/dr-ui/src/library_ui/ratings_keywords.rs
T
dtourolle 1e472fd251 Move the library's routes and status onto its own Slint global
The scan/opening/status/error lines, offline mode and its retry, pinning a
collection offline, the sync and thumbnail-sweep state, and the callbacks
that route the grid to a rescan, a sync, another library, a panorama merge
or an export — the last of what AppWindow still carried under the
library- prefix — move onto the `Library` global started earlier on this
branch. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_/invoke_ on the root.

library-visible is the one name that stays: it is computed from
active-page and active-view, the shell's own routing state, which a
global cannot read. AppWindow now declares no other library- property or
callback.
2026-09-20 22:22:55 +02:00

1071 lines
40 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));
if let Some(mut cell) = model.row_data(row) {
if cell.rating != rating || cell.flag != flag {
cell.rating = rating;
cell.flag = flag;
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);
}
// --- 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
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,
},
})
});
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_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);
});
}
// 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.
{
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| {
let Some(w) = weak.upgrade() else { return };
let chosen = coll_for_keys.selected();
// 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)));
}
});
}
}
/// --- 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 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"
);
}
}