Narrow the grid to several people at once, either way
"Show photos" could only ever mean one person. The two questions a photographer actually asks are "every picture of Anna or Bob" and "the pictures they are both in", and the second is not reachable by any sequence of single-person filters — no amount of switching between one person and another finds the frame they share. So the filter holds a *set* of people and a mode. `RatingFilter` was already the right home, as its own doc says: every query path threads it, so the count in the header and the cells in the grid are narrowed by the same thing, and this composes with stars, flags and the date range for free. The union is `EXISTS ... person_id IN (...)`. The intersection counts **distinct** people per image and compares against the size of the selection — one subquery rather than one per person, and it does not grow the statement with the selection. `DISTINCT` is what makes it correct: three faces of Anna in one frame must not satisfy a filter asking for Anna and Bob, and there is a test that says so. Any rather than All is the default. With one person the modes are the same filter, and adding a second to a union can only ever show more — so a user who has not noticed the toggle never ends up staring at an empty grid wondering what they broke. The toggle only appears at two people, because a control that demonstrably does nothing is a control that teaches the user to ignore it. Building the set needs no picker of its own: the Identity screen gains "And also…" beside "Show photos", offered only once the grid is already narrowed to somebody. Each person is a chip on the filter bar and each chip removes just that person, so a selection of three can be taken apart one at a time rather than only cleared wholesale. `RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path already took it by reference; the casualties were two struct updates and one `Cell` that becomes a `RefCell`. 484 dr-ui tests pass, including the union, the intersection, that one person reads the same in both modes, and the repeated-faces trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+102
-34
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
|
||||
use slint::{ComponentHandle, Model as _};
|
||||
|
||||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||||
use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar};
|
||||
use crate::{AppWindow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
|
||||
|
||||
/// A screenful before the grid has reported its geometry.
|
||||
///
|
||||
@@ -77,7 +77,8 @@ const MAX_CELL_SIZE: f32 = 420.0;
|
||||
/// every load for the scrollbar, and a scan landing, a delete or a restore all
|
||||
/// move it. What it cannot see — a rating edited under an unchanged count — is
|
||||
/// covered because the paths that do that refresh the chips themselves.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct LibraryFacts {
|
||||
scope: Option<dr_types::CollectionId>,
|
||||
filter: library::RatingFilter,
|
||||
@@ -141,7 +142,7 @@ pub struct LibraryController {
|
||||
window: RefCell<usize>,
|
||||
/// What the whole-library readouts on screen were last computed for, so a
|
||||
/// window that merely moved does not recompute them. See [`LibraryFacts`].
|
||||
library_facts: std::cell::Cell<Option<LibraryFacts>>,
|
||||
library_facts: RefCell<Option<LibraryFacts>>,
|
||||
/// How many cells the viewport shows at once, as the grid last reported.
|
||||
///
|
||||
/// Kept beside `window` rather than divided back out of it, because
|
||||
@@ -347,7 +348,7 @@ impl LibraryController {
|
||||
resume_at: std::cell::Cell::new(0),
|
||||
window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)),
|
||||
viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS),
|
||||
library_facts: std::cell::Cell::new(None),
|
||||
library_facts: RefCell::new(None),
|
||||
requested: RefCell::new(Default::default()),
|
||||
scan_timer: RefCell::new(None),
|
||||
thumb_timer: RefCell::new(None),
|
||||
@@ -2034,7 +2035,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// looking at, which on a remote library is the cost FR-NC-3 exists to
|
||||
// avoid.
|
||||
let scope = *ctl.scope.borrow();
|
||||
let filter = *ctl.filter.borrow();
|
||||
let filter = ctl.filter.borrow().clone();
|
||||
// The trash lists what every other view excludes, so it takes its own
|
||||
// query rather than another predicate threaded through the scoped one.
|
||||
let trash = ctl.viewing_trash.get();
|
||||
@@ -2048,12 +2049,12 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
// What the whole-library readouts below describe. See [`LibraryFacts`].
|
||||
let facts = LibraryFacts {
|
||||
scope,
|
||||
filter,
|
||||
filter: filter.clone(),
|
||||
trash,
|
||||
total,
|
||||
};
|
||||
let describes_something_new = ctl.library_facts.get() != Some(facts);
|
||||
ctl.library_facts.set(Some(facts));
|
||||
let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts);
|
||||
*ctl.library_facts.borrow_mut() = Some(facts);
|
||||
|
||||
// TRACES: FR-NC-6a
|
||||
// Whether the newly scoped collection is already pinned. Read here rather
|
||||
@@ -3790,7 +3791,7 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
|
||||
// over the whole library's span said almost nothing: every bar for a
|
||||
// fortnight in Arosa landed in one column of a fifteen-year axis.
|
||||
let scope = *ctl.scope.borrow();
|
||||
let filter = *ctl.filter.borrow();
|
||||
let filter = ctl.filter.borrow().clone();
|
||||
// Passed whole, date range included, and both queries below lift that
|
||||
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
|
||||
// term still applies, because a histogram of the five-star frames is a
|
||||
@@ -5049,31 +5050,23 @@ pub fn wire<F>(
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_identity_show_photos(move |id| {
|
||||
window.on_identity_show_photos(move |id, add| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let person = dr_catalog::faces::PersonId(id.max(0) as u64);
|
||||
|
||||
// The chip needs a name, and an unnamed group has none — so it
|
||||
// borrows the rail's own wording rather than inventing a second
|
||||
// way to describe the same thing.
|
||||
let label = {
|
||||
let borrow = ctl.catalog.borrow();
|
||||
borrow
|
||||
.as_ref()
|
||||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||||
.and_then(|people| people.into_iter().find(|p| p.id == person))
|
||||
.map(|p| {
|
||||
if p.name.trim().is_empty() {
|
||||
format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces)
|
||||
} else {
|
||||
p.name
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "Person".to_string())
|
||||
};
|
||||
|
||||
ctl.filter.borrow_mut().person = Some(person.0);
|
||||
w.set_library_filter_person_name(label.into());
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
if !add {
|
||||
f.people.clear();
|
||||
}
|
||||
// Adding the same person twice is a no-op rather than a
|
||||
// duplicate chip — and, under `All`, rather than a filter that
|
||||
// silently asks for them twice and matches the same pictures.
|
||||
if !f.people.contains(&person.0) {
|
||||
f.people.push(person.0);
|
||||
}
|
||||
}
|
||||
push_people_chips(&w, &ctl);
|
||||
|
||||
// Leaving the Identity screen for the grid is the whole point of
|
||||
// the button: the answer to "who is this" is a set of photographs,
|
||||
@@ -5087,10 +5080,30 @@ pub fn wire<F>(
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_person_cleared(move || {
|
||||
window.on_library_filter_person_cleared(move |id| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl.filter.borrow_mut().person = None;
|
||||
w.set_library_filter_person_name(Default::default());
|
||||
ctl.filter
|
||||
.borrow_mut()
|
||||
.people
|
||||
.retain(|p| *p != id.max(0) as u64);
|
||||
push_people_chips(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_filter_people_mode_toggled(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
{
|
||||
let mut f = ctl.filter.borrow_mut();
|
||||
f.people_mode = match f.people_mode {
|
||||
library::PeopleMode::Any => library::PeopleMode::All,
|
||||
library::PeopleMode::All => library::PeopleMode::Any,
|
||||
};
|
||||
}
|
||||
push_people_chips(&w, &ctl);
|
||||
refilter(&w, &ctl);
|
||||
});
|
||||
}
|
||||
@@ -5424,6 +5437,61 @@ pub fn wire<F>(
|
||||
/// The offset is reset because it is an ordinal into the filtered set: keeping
|
||||
/// it would land the user in the middle of a narrowed library with no sense of
|
||||
/// how they got there, or past its end entirely.
|
||||
/// Put the identity filter's people on the filter bar.
|
||||
///
|
||||
/// One chip each, so a selection of three is three things to look at and three
|
||||
/// things to remove — a single chip saying "3 people" would be a filter the
|
||||
/// user can only clear wholesale.
|
||||
///
|
||||
/// The label is the People screen's own wording, unnamed groups included, so
|
||||
/// the same group does not read one way on one screen and another way on the
|
||||
/// next.
|
||||
fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
let ids = ctl.filter.borrow().people.clone();
|
||||
if ids.is_empty() {
|
||||
window
|
||||
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
|
||||
Vec::<PersonChip>::new(),
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
let borrow = ctl.catalog.borrow();
|
||||
let people = borrow
|
||||
.as_ref()
|
||||
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let chips: Vec<PersonChip> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let name = people
|
||||
.iter()
|
||||
.find(|p| p.id.0 == *id)
|
||||
.map(|p| {
|
||||
if p.name.trim().is_empty() {
|
||||
format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces)
|
||||
} else {
|
||||
p.name.clone()
|
||||
}
|
||||
})
|
||||
// A person deleted from under the filter still needs a chip,
|
||||
// or there would be no way to take them back out of it.
|
||||
.unwrap_or_else(|| "Person".to_string());
|
||||
PersonChip {
|
||||
id: *id as i32,
|
||||
name: name.into(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips)));
|
||||
window.set_library_filter_people_all(matches!(
|
||||
ctl.filter.borrow().people_mode,
|
||||
library::PeopleMode::All
|
||||
));
|
||||
}
|
||||
|
||||
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
*ctl.offset.borrow_mut() = 0;
|
||||
ctl.requested.borrow_mut().clear();
|
||||
|
||||
Reference in New Issue
Block a user