Merge branch 'worktree-agent-abfe489c84c337e7c' into integration
This commit is contained in:
+264
-1
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||||
use crate::{AppWindow, LibraryCell, TimelineBar};
|
||||
use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar};
|
||||
|
||||
/// Window size before the grid has reported its geometry.
|
||||
///
|
||||
@@ -2117,6 +2117,170 @@ fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||||
window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
||||
}
|
||||
|
||||
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
|
||||
//
|
||||
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
|
||||
// join, what a rename does to the assignments. This part owns the *interaction*:
|
||||
// which photographs the sheet is acting on, and keeping what it draws honest
|
||||
// about what actually landed.
|
||||
|
||||
/// Redraw the keywording sheet against whatever is selected now.
|
||||
///
|
||||
/// Called when the sheet opens and after every assignment, rather than on every
|
||||
/// selection change: the selection moves on each arrow key and the sheet is shut
|
||||
/// for almost all of them, so computing coverage over a forty-image selection
|
||||
/// on each one would be work nobody is looking at.
|
||||
///
|
||||
/// Re-read from the catalog rather than patched in place after a write. A word
|
||||
/// applied to a selection that partly already had it moves from "3 of 12" to
|
||||
/// "12 of 12", and a model updated by hand would have to reproduce the rule
|
||||
/// that decides that — which is exactly the rule the catalog has just applied.
|
||||
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
// The grid is entirely usable without the sheet, so this is logged
|
||||
// rather than surfaced: a keyword read that failed must not put an
|
||||
// error banner over a library the user is browsing.
|
||||
log::debug!("reading keywords: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let model: Vec<KeywordRow> = rows
|
||||
.into_iter()
|
||||
.map(|row| KeywordRow {
|
||||
id: row.keyword.id.0 as i32,
|
||||
name: row.keyword.name.into(),
|
||||
coverage: match row.coverage {
|
||||
dr_catalog::Coverage::None => 0,
|
||||
dr_catalog::Coverage::Some => 1,
|
||||
dr_catalog::Coverage::All => 2,
|
||||
},
|
||||
selected_count: row.selected_count as i32,
|
||||
image_count: row.keyword.image_count as i32,
|
||||
})
|
||||
.collect();
|
||||
|
||||
window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
|
||||
}
|
||||
|
||||
/// Put a keyword on the selection, or take it off.
|
||||
///
|
||||
/// # Why this does not write a sidecar
|
||||
///
|
||||
/// Every other judgement in this file — a star, a flag — is written to the
|
||||
/// catalog and then queued to the image's sidecar, because the sidecar is what
|
||||
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
|
||||
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
|
||||
/// `flag` and nothing else that is not an edit-graph parameter.
|
||||
///
|
||||
/// So a keyword is, for now, catalog state that reaches the user's other
|
||||
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
|
||||
/// through the sidecar. That is a real limitation and not a silent one: a
|
||||
/// deleted catalog loses keywords where it would keep ratings, until the
|
||||
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
|
||||
/// queued write the stars have.
|
||||
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
|
||||
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
|
||||
return;
|
||||
};
|
||||
let images = coll.selected();
|
||||
|
||||
// The word as it will be *stored*, resolved before anything is written.
|
||||
// The status line below quotes it back, and quoting what was typed would
|
||||
// report a leading space the catalog is about to drop — leaving the user to
|
||||
// wonder whether it mattered.
|
||||
//
|
||||
// This is also where a blank keyword is caught, which is why it happens
|
||||
// before the selection check: "you typed nothing" is a better answer than
|
||||
// "select an image first" to someone who pressed return on an empty field.
|
||||
let word = match dr_catalog::keywords::normalise(word) {
|
||||
Ok(word) => word,
|
||||
Err(e) => {
|
||||
// `BadName` carries text written to be read by the user rather than
|
||||
// by a developer, so it is shown as it is.
|
||||
window.set_library_error(format!("{e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Assigning with nothing selected still means something — it puts the word
|
||||
// in the vocabulary, ready for the photographs it was typed for — so only
|
||||
// the removal half needs a selection to act on.
|
||||
if images.is_empty() && !assigning {
|
||||
window.set_library_status("Select an image first".into());
|
||||
return;
|
||||
}
|
||||
|
||||
let outcome = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let Some(catalog) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let conn = catalog.connection();
|
||||
if assigning {
|
||||
dr_catalog::keywords::assign(conn, &images, &word)
|
||||
} else {
|
||||
dr_catalog::keywords::unassign(conn, &images, &word)
|
||||
}
|
||||
};
|
||||
|
||||
let n = match outcome {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
window.set_library_error(format!("{e}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
|
||||
refresh_keywords(window, ctl, &images);
|
||||
|
||||
// A filtered grid may no longer hold what was just keyworded — taking
|
||||
// "puffin" off an image while showing only puffins means it belongs
|
||||
// elsewhere now. The same reasoning as a rating that falls below the star
|
||||
// filter.
|
||||
if !ctl.filter.borrow().is_unfiltered() {
|
||||
load_window(window, ctl);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status line says about a keyword that just landed.
|
||||
///
|
||||
/// The honest count, not the requested one: "added to 3 of 12" is what
|
||||
/// happened when nine of them already carried the word, and a message that
|
||||
/// claimed twelve would be teaching the user that the counts are decorative.
|
||||
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
|
||||
if selected == 0 {
|
||||
return format!("Added “{word}” to the keyword list");
|
||||
}
|
||||
let verb = if assigning { "Added" } else { "Removed" };
|
||||
let preposition = if assigning { "to" } else { "from" };
|
||||
if changed == 0 {
|
||||
return if assigning {
|
||||
format!("Every selected photograph already had “{word}”")
|
||||
} else {
|
||||
format!("None of the selected photographs had “{word}”")
|
||||
};
|
||||
}
|
||||
if changed == selected {
|
||||
let what = if selected == 1 {
|
||||
"1 photograph".to_string()
|
||||
} else {
|
||||
format!("{selected} photographs")
|
||||
};
|
||||
return format!("{verb} “{word}” {preposition} {what}");
|
||||
}
|
||||
format!("{verb} “{word}” {preposition} {changed} of {selected}")
|
||||
}
|
||||
|
||||
/// Apply a judgement to a set of images: catalog first, then sidecars.
|
||||
///
|
||||
/// # Order matters
|
||||
@@ -4152,6 +4316,40 @@ pub fn wire<F>(
|
||||
});
|
||||
}
|
||||
|
||||
// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
|
||||
//
|
||||
// Three callbacks and no state of their own: the sheet's open/shut is local
|
||||
// to the `.slint` file, and what a keyword applies to is the grid selection
|
||||
// the collections controller already owns. A second copy of either here is
|
||||
// a second thing that can disagree with the first.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll_for_keywords = coll_ctl.clone();
|
||||
window.on_library_keywords_opened(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_assign_keyword(move |word| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
apply_keyword(&w, &ctl, word.as_str(), true);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_unassign_keyword(move |word| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
apply_keyword(&w, &ctl, word.as_str(), false);
|
||||
});
|
||||
}
|
||||
|
||||
// --- the filter bar ---------------------------------------------------
|
||||
//
|
||||
// Each of these narrows what the grid *queries*, so all three reset the
|
||||
@@ -5017,6 +5215,71 @@ mod tests {
|
||||
assert_eq!(paths, vec!["c.CR2", "a.CR2"]);
|
||||
}
|
||||
|
||||
// --- what the status line says about a keyword (FR-CAT-5) -------------
|
||||
//
|
||||
// Split out from the callback for the same reason `decide_drop` is: the
|
||||
// sheet cannot be driven from a test, and this is the part that can
|
||||
// actually mislead someone.
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn a_partly_applied_keyword_reports_the_honest_count() {
|
||||
// Nine of the twelve already had it. Claiming twelve is how a user
|
||||
// learns that the counts are decorative.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 3, 12, true),
|
||||
"Added “puffin” to 3 of 12"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 12, true),
|
||||
"Every selected photograph already had “puffin”"
|
||||
);
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 12, false),
|
||||
"None of the selected photographs had “puffin”"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn one_photograph_is_singular() {
|
||||
// "Added to 1 photographs" is the kind of small wrongness that makes
|
||||
// the rest of the interface look unfinished.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 1, 1, true),
|
||||
"Added “puffin” to 1 photograph"
|
||||
);
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 2, 2, true),
|
||||
"Added “puffin” to 2 photographs"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn removing_a_keyword_reads_as_removal() {
|
||||
assert_eq!(
|
||||
keyword_summary("blurry", 4, 4, false),
|
||||
"Removed “blurry” from 4 photographs"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-5
|
||||
#[test]
|
||||
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
|
||||
// It builds the vocabulary, which is a legitimate thing to do ahead of
|
||||
// a shoot — so it must not report itself as having keyworded nothing.
|
||||
assert_eq!(
|
||||
keyword_summary("puffin", 0, 0, true),
|
||||
"Added “puffin” to the keyword list"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-7
|
||||
#[test]
|
||||
fn a_selection_outside_the_loaded_window_still_resolves() {
|
||||
|
||||
Reference in New Issue
Block a user