Put the people filter where the filters are

Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:32:10 +02:00
co-authored by Claude Opus 5
parent 2403f355d6
commit a4b9deaf96
3 changed files with 240 additions and 1 deletions
+102
View File
@@ -5097,6 +5097,34 @@ pub fn wire<F>(
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
}
}
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -5459,6 +5487,9 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
Vec::<PersonChip>::new(),
)));
// The tray, if it is open, has to lose its ticks with the chips: the
// roster carries `picked` and is the same fact drawn a second time.
push_people_roster(window, ctl);
return;
}
@@ -5487,6 +5518,12 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
PersonChip {
id: *id as i32,
name: name.into(),
// Not asked for on this side. The bar's job here is to say who
// the grid is narrowed to and offer a way out of it; a face
// count beside each would be a second number competing with the
// image counts already on the bar.
faces: -1,
picked: true,
}
})
.collect();
@@ -5496,6 +5533,71 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
push_people_roster(window, ctl);
}
/// Fill the filter bar's people tray.
///
/// Rebuilt whole rather than patched, because `picked` is on every row and a
/// toggle changes two things at once — the chip that was pressed and, under
/// `All`, what the whole filter means.
///
/// **Named people first, then by how much of them the library holds.** The
/// catalog orders by face count alone, which on a real library puts a dozen
/// unnamed strangers ahead of the two people the user has actually named — and
/// the tray scrolls horizontally, so anything past the first few chips costs a
/// gesture to reach. Naming somebody is the user saying they matter; the order
/// says it back.
fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
let picked = ctl.filter.borrow().people.clone();
let borrow = ctl.catalog.borrow();
let people = borrow
.as_ref()
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
.unwrap_or_default();
// Sorted as (unnamed, -faces) pairs beside the chip rather than by
// re-reading the drawn label: "is this person named" is a fact about the
// record, and recovering it from the `Unnamed (n faces)` wording would put
// a sort key inside a string meant for a human to read.
let mut rows: Vec<(bool, i64, PersonChip)> = people
.iter()
.filter(|p| {
let on = picked.contains(&p.id.0);
// A person already in the filter always has a chip, whatever else
// is true of them: the tray is where the filter is taken apart, and
// a term with no control is a term the user cannot remove.
//
// Otherwise: nobody the user set aside, and nobody with no faces —
// a named person emptied by a split would be a chip that narrows
// the grid to nothing whatever else is on the bar.
on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0)
})
.map(|p| {
let faces = p.confirmed_faces + p.suggested_faces;
let unnamed = p.name.trim().is_empty();
(
unnamed,
faces as i64,
PersonChip {
id: p.id.0 as i32,
name: if unnamed {
format!("Unnamed ({faces} faces)").into()
} else {
p.name.clone().into()
},
faces: faces as i32,
picked: picked.contains(&p.id.0),
},
)
})
.collect();
// Stable, so the catalog's own tiebreak by name survives inside each of the
// two blocks.
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
}
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {