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:
+135
-6
@@ -75,6 +75,14 @@ pub struct IdentityController {
|
||||
/// two people called Anna are exactly the case this feature exists for, so
|
||||
/// re-deriving the target from the displayed text could pick the wrong one.
|
||||
merge_offer: std::cell::Cell<Option<PersonId>>,
|
||||
/// The running regrouping pass, if any.
|
||||
///
|
||||
/// Same shape as `sweep`, and for the same reason: holding the receiver is
|
||||
/// the handle, and clustering a real library is not something a Slint
|
||||
/// callback may do on the UI thread.
|
||||
regroup: RefCell<Option<Receiver<crate::faces::ReclusterMessage>>>,
|
||||
/// Whether the rail is showing the people the user has set aside.
|
||||
show_ignored: std::cell::Cell<bool>,
|
||||
/// Rail portraits, kept between refreshes.
|
||||
///
|
||||
/// Every mutating action reloads the whole screen, and cutting a portrait
|
||||
@@ -153,10 +161,13 @@ pub fn refresh(
|
||||
suggested_faces: p.suggested_faces as i32,
|
||||
has_cover: cover.is_some(),
|
||||
cover: cover.unwrap_or_default(),
|
||||
ignored: p.ignored,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
window.set_identity_people(ModelRc::new(VecModel::from(rows)));
|
||||
window.set_identity_ignored_count(view.people.iter().filter(|p| p.ignored).count() as i32);
|
||||
window.set_identity_show_ignored(ctl.show_ignored.get());
|
||||
*ctl.people.borrow_mut() = view.people;
|
||||
|
||||
// The selected person may have just been merged away or deleted.
|
||||
@@ -195,10 +206,18 @@ pub fn refresh(
|
||||
.unwrap_or_default();
|
||||
window.set_identity_selected(person.0 as i32);
|
||||
window.set_identity_selected_name(name.into());
|
||||
window.set_identity_selected_ignored(
|
||||
ctl.people
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|p| p.id == person)
|
||||
.is_some_and(|p| p.ignored),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
window.set_identity_selected(-1);
|
||||
window.set_identity_selected_name(Default::default());
|
||||
window.set_identity_selected_ignored(false);
|
||||
window.set_identity_faces(ModelRc::new(VecModel::from(Vec::<IdentityFace>::new())));
|
||||
ctl.faces.borrow_mut().clear();
|
||||
}
|
||||
@@ -662,15 +681,88 @@ pub fn wire<S, M, P>(
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
let paths = paths.clone();
|
||||
window.on_identity_recluster(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(cat) = catalog.borrow().as_ref() {
|
||||
match crate::faces::recluster(cat, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) {
|
||||
Ok((s, c)) => log::info!("identity: {s} suggestion(s), {c} new group(s)"),
|
||||
Err(e) => log::warn!("identity: recluster: {e}"),
|
||||
}
|
||||
// One at a time. Two passes over the same faces would each create
|
||||
// their own groups for the same clusters, and the second would
|
||||
// undo the first's pruning.
|
||||
if ctl.regroup.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
let Some((_, _, catalog_path, _)) = paths() else {
|
||||
return;
|
||||
};
|
||||
|
||||
w.set_identity_regrouping(true);
|
||||
w.set_identity_regroup_status("regrouping…".into());
|
||||
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
|
||||
catalog_path,
|
||||
MODEL_ID.to_string(),
|
||||
dr_face::DEFAULT_MERGE_PROBABILITY,
|
||||
));
|
||||
|
||||
// Polled from the UI thread, like the indexing sweep: the worker
|
||||
// is a plain thread with a channel, and every Slint property write
|
||||
// has to happen where Slint requires it.
|
||||
let timer = slint::Timer::default();
|
||||
let weak_tick = w.as_weak();
|
||||
let ctl_tick = ctl.clone();
|
||||
let catalog_tick = catalog.clone();
|
||||
let store_tick = store.clone();
|
||||
timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
Duration::from_millis(100),
|
||||
move || {
|
||||
let Some(w) = weak_tick.upgrade() else { return };
|
||||
let mut done = false;
|
||||
{
|
||||
let borrow = ctl_tick.regroup.borrow();
|
||||
let Some(rx) = borrow.as_ref() else { return };
|
||||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
crate::faces::ReclusterMessage::Started { faces } => {
|
||||
w.set_identity_regroup_status(
|
||||
format!("regrouping {faces} face(s)…").into(),
|
||||
);
|
||||
}
|
||||
crate::faces::ReclusterMessage::Finished {
|
||||
suggested,
|
||||
created,
|
||||
} => {
|
||||
log::info!(
|
||||
"identity: {suggested} suggestion(s), {created} new group(s)"
|
||||
);
|
||||
w.set_identity_regroup_status(
|
||||
format!(
|
||||
"{created} new group(s), {suggested} suggestion(s)"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
done = true;
|
||||
}
|
||||
crate::faces::ReclusterMessage::Failed(e) => {
|
||||
log::warn!("identity: recluster: {e}");
|
||||
w.set_identity_regroup_status(
|
||||
format!("regrouping failed: {e}").into(),
|
||||
);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
*ctl_tick.regroup.borrow_mut() = None;
|
||||
w.set_identity_regrouping(false);
|
||||
// Portraits are keyed on the person, and reclustering
|
||||
// makes new people; a stale cache would draw the
|
||||
// previous pass's faces beside the new groups.
|
||||
ctl_tick.covers.borrow_mut().clear();
|
||||
refresh(&w, &ctl_tick, &catalog_tick, store_tick().as_deref());
|
||||
}
|
||||
},
|
||||
);
|
||||
park_timer(timer);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,6 +902,43 @@ pub fn wire<S, M, P>(
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
window.on_identity_ignore_person(move |id, on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let person = PersonId(id.max(0) as u64);
|
||||
if let Some(cat) = catalog.borrow().as_ref() {
|
||||
if let Err(e) = dr_catalog::faces::set_ignored(cat.connection(), person, on) {
|
||||
log::warn!("identity: setting a person aside: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Move off the person just set aside, or the screen sits on a
|
||||
// group the rail no longer shows — which reads as the button
|
||||
// having done nothing.
|
||||
if on && ctl.selected.get() == Some(person) {
|
||||
ctl.selected.set(None);
|
||||
ctl.clear_picks();
|
||||
}
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let catalog = catalog.clone();
|
||||
let store = store.clone();
|
||||
window.on_identity_toggle_show_ignored(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl.show_ignored.set(!ctl.show_ignored.get());
|
||||
reload!(w, ctl, catalog, store);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
|
||||
Reference in New Issue
Block a user