Give the library somewhere to type a keyword
A sheet over the grid, opened from the header beside "Add to collection" — deliberately the same card, scrim and dismissal as the filing sheet, because they are the same gesture applied to two kinds of label: pick the photographs, then say what they are. A user who has filed a selection already knows how this works. A word the whole selection carries, a word only some of it carries, and a word none of it carries are three visibly different marks. Half-applied shown as applied would be a lie about photographs the user cannot see from here, so a partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash completes the keyword rather than removing it, which is what it means nine times in ten, and the tenth is one more tap away. The vocabulary is answered against the selection in Rust and pulled when the sheet opens rather than pushed on every selection change — the selection moves on each arrow key and the sheet is shut for almost all of them. Assign and unassign travel by name, so a word typed into the field and a word tapped in the list are one path rather than two, and the sheet never has to invent an identity for a keyword that does not exist yet. One gap, commented at the call site: unlike a star or a flag, a keyword is not queued to the image's sidecar, because the sidecar format has no field for one. So it reaches the user's other devices through the catalog merge, and a deleted catalog loses keywords where it would keep ratings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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