Show and set colour labels in the grid and develop, and filter by them
Colour labels could be read from a Lightroom sidecar and queried by the selector, but nothing drew one or set one, so the only labels a library held were ones another program had written. Every mark carries its label's initial on its colour — R, Y, G, B, P — so a label is read without telling red from green, which is what NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and blue as Lightroom's keys do, on the photograph under the pointer or on the selection by the rule the star keys follow; the same key again takes the label off, and over a mixed selection it sets it on all. The selection bar gains Label, which opens the six choices — each a mark and a name — and purple, which has no key, is there. In develop the top bar says "Label: Green" beside the mark, opens the same choices, and 6-9 label the open photograph. Each gesture is one catalog transaction, then the grid, the counts and both sidecars are written as a rating's are. The filter bar gains a chip per label, its mark and its name with a count, one at a time; the filter is one SQL term, travels in the place record, and "All" clears it.
This commit is contained in:
@@ -252,6 +252,24 @@ pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc<Librar
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-6
|
||||
// A colour label chip. Independent of the stars and the flag: "the red
|
||||
// ones I have not starred yet" is a question worth being able to ask.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window
|
||||
.global::<Library>()
|
||||
.on_library_filter_label_changed(move |code| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let label = dr_catalog::rating::label_from_code(Some(code as i64));
|
||||
ctl.filter.borrow_mut().label = label;
|
||||
w.global::<Library>()
|
||||
.set_library_filter_label(label.map_or(0, |_| code));
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The date-range filter: turned on from the timeline's own visible span,
|
||||
|
||||
@@ -45,11 +45,15 @@ pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::Imag
|
||||
// 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +82,123 @@ pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
|
||||
.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
|
||||
@@ -672,6 +793,7 @@ fn drain_xmp(
|
||||
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);
|
||||
}
|
||||
@@ -959,6 +1081,69 @@ pub(super) fn wire_ratings_and_flags(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1054,6 +1239,37 @@ mod tests {
|
||||
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
|
||||
|
||||
@@ -927,6 +927,11 @@ fn push_filter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
window
|
||||
.global::<Library>()
|
||||
.set_library_filter_eyes_open(filter.eyes_open);
|
||||
window.global::<Library>().set_library_filter_label(
|
||||
filter
|
||||
.label
|
||||
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32),
|
||||
);
|
||||
push_people_chips(window, ctl);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{AppWindow, Library, LibraryCell};
|
||||
use super::controller::{stop, LibraryController, LibraryFacts};
|
||||
use super::filter_bar::period_headings;
|
||||
use super::offline::{collection_images, refresh_offline, scope_is_pinned};
|
||||
use super::ratings_keywords::{refresh_rating_counts, sync_ratings};
|
||||
use super::ratings_keywords::{refresh_label_counts, refresh_rating_counts, sync_ratings};
|
||||
use super::sync::start_sweep;
|
||||
use super::timeline::{civil_from_unix, format_date, refresh_timeline, restore_position};
|
||||
|
||||
@@ -367,6 +367,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// window, not one per cell.
|
||||
rating: 0,
|
||||
flag: 0,
|
||||
label: 0,
|
||||
// And by `bursts::sync_badges`, in one more query for the
|
||||
// window. Zero is "not in a burst", which is what almost every
|
||||
// photograph in a library is.
|
||||
@@ -424,6 +425,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// so a cull still watches its own chips move.
|
||||
if describes_something_new {
|
||||
refresh_rating_counts(window, catalog);
|
||||
refresh_label_counts(window, catalog);
|
||||
}
|
||||
|
||||
// The library got shorter while the view was looking at it — a delete.
|
||||
|
||||
Reference in New Issue
Block a user