Make the People screen a place work can be done

Five faults, all on one screen, and the Slint and Rust halves of each have to
land together.

**Regroup froze the window.** It ran inside the Slint callback, on the UI
thread. It is much faster now, but fast is not bounded — the work grows with the
library, and the one thing that must not grow with the library is how long the
window stops answering. It runs on a worker thread with an mpsc channel and a
250ms poll, like every other long pass in this module, and the button says what
it is doing instead of the window going quiet. Cancellation is dropping the
receiver. Reclustering also prunes the empty groups the previous pass left, so
pressing the button twice no longer fills the rail with "Unnamed (0 faces)".

**The faces were a single row running off the screen.** The comment on the
layout claimed to be a wrapping row; Slint has no flow layout and a
HorizontalLayout does not wrap, so a person with forty faces was a person whose
faces could not be reviewed past the fifth. It is now laid out the way the
library grid lays out thumbnails, with the same arithmetic: choose how many
columns of roughly the requested size fit, then divide the width between them so
the cells fill the row exactly and nothing overhangs.

**The header did not fit a phone.** A 240px name field beside five buttons is
wider than an Android screen — and worse than not fitting, a layout cannot be
narrower than its children's minimums, so the row reported that oversized
minimum upwards and inflated the whole screen. The faces grid is its sibling, so
it would have been measured against a width that was never on the display. The
header is now two rows, the actions sit in a Flickable that scrolls rather than
overflowing, and the rail narrows to 132px on the compact class.

**Strangers crowded out the people who matter.** Most clusters in a real library
are passers-by and other people's guests. "Not interested" sets a group aside;
the rail hides it and says how many are hidden, with one button to bring them
back. Reversible, and never a deletion — see the catalog commit for why.

**A face was a dead end.** Identifying someone and then having no way to see
their photographs is a filing cabinet with no drawer handles. "Show photos"
narrows the library grid to that person and leaves a chip on the filter bar
saying so, which is also how it is cleared. It is a term on `RatingFilter`
rather than a grid scope of its own, exactly as that struct's own doc says new
narrowing terms should be — so the count and the cells are narrowed by the same
thing, and it composes with the others for free. Suggested faces count, not only
confirmed ones, or a freshly grouped person would show an empty grid.

Crops are read from where they are now stored, falling back to cutting one out
of the proxy for faces indexed before that existed.

480 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:21:31 +02:00
co-authored by Claude Opus 5
parent 79c0520506
commit d6380fecc8
8 changed files with 634 additions and 45 deletions
+60 -15
View File
@@ -51,6 +51,8 @@ pub struct PersonRow {
pub suggested_faces: u64,
/// The face to show as this person's portrait, if any is loaded.
pub cover: Option<FaceId>,
/// Set aside by the user — see `faces::set_ignored`.
pub ignored: bool,
}
impl PersonRow {
@@ -138,6 +140,7 @@ pub fn load_people(
confirmed_faces: p.confirmed_faces,
suggested_faces: p.suggested_faces,
cover: None,
ignored: p.ignored,
})
.collect();
@@ -168,21 +171,31 @@ pub fn load_faces(
let conn = catalog.connection();
let rows = faces::for_person(conn, person, true)?;
// One decode per *image*, not per face: a group photograph holding six
// faces of one family is one JPEG, and decoding it six times is the kind of
// waste that only shows up on a slow machine.
// The crops kept at detection time, in one query. Where a face has one this
// is the whole cost of drawing it — no proxy, no full-size JPEG decode, and
// no dependence on the thumbnail cache still holding the photograph.
let stored = faces::crops_for_person(conn, person, true)?;
// The fallback path, for faces indexed before crops were kept. One decode
// per *image*, not per face: a group photograph holding six faces of one
// family is one JPEG, and decoding it six times is the kind of waste that
// only shows up on a slow machine.
let mut decoded: std::collections::HashMap<ImageId, Option<(u32, u32, Vec<u8>)>> =
std::collections::HashMap::new();
let mut out = Vec::with_capacity(rows.len());
for f in rows {
let entry = decoded
.entry(f.image_id)
.or_insert_with(|| decode_proxy(catalog, store, f.image_id));
let crop = entry
.as_ref()
.and_then(|(w, h, rgba)| crop_face(rgba, *w, *h, &f, FACE_CROP_EDGE));
let crop = match stored.get(&f.id).and_then(|b| decode_crop(b)) {
Some(c) => Some(c),
None => {
let entry = decoded
.entry(f.image_id)
.or_insert_with(|| decode_proxy(catalog, store, f.image_id));
entry
.as_ref()
.and_then(|(w, h, rgba)| crop_face(rgba, *w, *h, &f, FACE_CROP_EDGE))
}
};
out.push(FaceCell {
face: f.id,
@@ -231,12 +244,25 @@ pub fn load_cover(
// already in preference order — so this gives up size only when the
// preferred face is genuinely too dark to recognise.
let mut fallback: Option<FaceCrop> = None;
let conn = catalog.connection();
for face in rows.iter().take(COVER_CANDIDATES) {
let Some((w, h, rgba)) = decode_proxy(catalog, store, face.image_id) else {
continue;
};
let Some(crop) = crop_face(&rgba, w, h, face, COVER_CROP_EDGE) else {
continue;
// A stored crop costs a small JPEG decode; the fallback costs a full
// proxy decode, which is what made the rail expensive to build.
let crop = match faces::crop(conn, face.id)
.ok()
.flatten()
.and_then(|b| decode_crop(&b))
{
Some(c) => c,
None => {
let Some((w, h, rgba)) = decode_proxy(catalog, store, face.image_id) else {
continue;
};
let Some(c) = crop_face(&rgba, w, h, face, COVER_CROP_EDGE) else {
continue;
};
c
}
};
if mean_luma(&crop) >= MIN_COVER_LUMA {
return Ok(Some(crop));
@@ -277,6 +303,21 @@ fn mean_luma(crop: &FaceCrop) -> f32 {
}
}
/// Turn a stored crop back into pixels.
///
/// Returned at whatever size it was stored (`faces::STORED_CROP_EDGE`) rather
/// than resampled down to the cell: the grid and the rail scale it themselves,
/// and doing it here would mean two resamples where one will do — and a soft
/// portrait for the trouble.
fn decode_crop(bytes: &[u8]) -> Option<FaceCrop> {
let (w, h, rgba) = dr_thumbs::codec::decode_rgba(bytes).ok()?;
Some(FaceCrop {
width: w,
height: h,
rgba,
})
}
fn decode_proxy(
catalog: &Catalog,
store: &ThumbStore,
@@ -485,6 +526,7 @@ pub fn namesake(
confirmed_faces: p.confirmed_faces,
suggested_faces: p.suggested_faces,
cover: None,
ignored: p.ignored,
}))
}
@@ -641,6 +683,7 @@ mod tests {
embedding: vec![seed; 1024],
crop_px: 180.0,
model_id: "w600k_mbf".into(),
crop: Vec::new(),
}
}
@@ -652,6 +695,7 @@ mod tests {
confirmed_faces: 0,
suggested_faces: 12,
cover: None,
ignored: false,
};
assert_eq!(row.display_name(), "Unnamed (12 faces)");
assert!(row.is_unconfirmed());
@@ -665,6 +709,7 @@ mod tests {
confirmed_faces: 4,
suggested_faces: 2,
cover: None,
ignored: false,
};
assert_eq!(row.display_name(), "Anna");
assert!(!row.is_unconfirmed());