Conflicts were docs/traceability.md alone, and it is generated — so it was regenerated rather than hand-merged. dr-face was untouched on the other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the first around recluster's anchoring and the second around load_faces. Worth recording because the two branches met on the same problem from different ends. Master's "Let a name hold a group together" is the fix for the sixteen Catherines — fourteen of them empty — that this branch found while measuring the library and reported without fixing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1002 lines
40 KiB
Rust
1002 lines
40 KiB
Rust
//! TRACES: FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | NFR-SEC-5
|
||
//! The Identity screen's wiring: catalog state onto Slint models and back.
|
||
//!
|
||
//! [`crate::identity`] holds the decisions and is testable without a window;
|
||
//! this file is the part that cannot be, and it is deliberately thin. Anything
|
||
//! here that starts making a judgement belongs over there.
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
use std::sync::mpsc::Receiver;
|
||
use std::time::Duration;
|
||
|
||
use dr_catalog::faces::{FaceId, PersonId};
|
||
use dr_catalog::Catalog;
|
||
use dr_thumbs::ThumbStore;
|
||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||
|
||
use crate::faces::FaceSweepMessage;
|
||
use crate::identity::{self, FaceCell, PersonRow};
|
||
use crate::{AppWindow, IdentityFace, IdentityPerson};
|
||
|
||
/// Which model's faces the screen is looking at.
|
||
///
|
||
/// A constant for now because exactly one model is supported at a time; it is
|
||
/// named rather than inlined because every query in this file is keyed on it,
|
||
/// and a library indexed across a model change holds faces from both.
|
||
pub const MODEL_ID: &str = "w600k_mbf";
|
||
|
||
/// Screen state that outlives a single callback.
|
||
#[derive(Default)]
|
||
pub struct IdentityController {
|
||
people: RefCell<Vec<PersonRow>>,
|
||
faces: RefCell<Vec<FaceCell>>,
|
||
selected: std::cell::Cell<Option<PersonId>>,
|
||
/// Faces the user has ticked, which is what a split would carry.
|
||
picked: RefCell<Vec<FaceId>>,
|
||
/// The running indexing sweep, if any.
|
||
///
|
||
/// Holding the receiver *is* the cancellation handle: the worker stops when
|
||
/// its send fails, so dropping this is how "Stop" works. No flag to get out
|
||
/// of step with the thread, and everything already written stays written.
|
||
sweep: RefCell<Option<Receiver<FaceSweepMessage>>>,
|
||
/// Images visited so far in the current sweep, and the total it announced.
|
||
progress: std::cell::Cell<(usize, usize)>,
|
||
faces_found: std::cell::Cell<usize>,
|
||
/// Whether opening this screen left the library rather than develop.
|
||
///
|
||
/// Recorded so leaving puts the user back where they were. The screen is
|
||
/// reachable from both other modes, and returning a photographer to the
|
||
/// grid when they came from an open photograph loses their place for no
|
||
/// reason.
|
||
came_from_library: std::cell::Cell<bool>,
|
||
/// The activity row for the running sweep.
|
||
///
|
||
/// Face indexing is an hours-long background pass, and the Settings page
|
||
/// promises its progress will appear in the list above the button. Without
|
||
/// a row it would not, and the button would be the only sign it was
|
||
/// running — invisible from every other screen.
|
||
activity: RefCell<Option<crate::activity::Activity>>,
|
||
/// The name being typed, and who it is being typed for.
|
||
///
|
||
/// `None` means the field has not been touched, which is **not** the same
|
||
/// as an empty draft: a user who cleared the field means to clear the name,
|
||
/// and a user who never typed means to leave it alone. Collapsing those two
|
||
/// would erase a name by navigating past it.
|
||
///
|
||
/// Carries the person because a reload can move the selection underneath a
|
||
/// half-typed name — a merge from the other side of a sync, a deletion —
|
||
/// and applying it to whoever is selected *now* would rename a stranger.
|
||
draft: RefCell<Option<(PersonId, String)>>,
|
||
/// The person a namesake merge is currently being offered against.
|
||
///
|
||
/// Held here rather than read back off the window because the window
|
||
/// carries the *name* for the strip to print, and a name is not a key —
|
||
/// 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
|
||
/// costs a JPEG decode per person. Without this, confirming one face would
|
||
/// re-decode a proxy for every person in the library — and the rail does
|
||
/// not change when a suggestion is accepted.
|
||
covers: RefCell<std::collections::HashMap<PersonId, slint::Image>>,
|
||
}
|
||
|
||
impl IdentityController {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// Clear the multi-select.
|
||
///
|
||
/// Called on every person change: a pick set that survived navigating to
|
||
/// another person would make the next "Split off" act on faces the user
|
||
/// can no longer see, which is the kind of surprise that loses data.
|
||
fn clear_picks(&self) {
|
||
self.picked.borrow_mut().clear();
|
||
}
|
||
}
|
||
|
||
/// Push the people rail and the face grid into the window.
|
||
pub fn refresh(
|
||
window: &AppWindow,
|
||
ctl: &IdentityController,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
store: Option<&ThumbStore>,
|
||
) {
|
||
let borrow = catalog.borrow();
|
||
let Some(cat) = borrow.as_ref() else {
|
||
window.set_identity_people(ModelRc::new(VecModel::from(Vec::<IdentityPerson>::new())));
|
||
window.set_identity_faces(ModelRc::new(VecModel::from(Vec::<IdentityFace>::new())));
|
||
return;
|
||
};
|
||
|
||
let view = match identity::load_people(cat, MODEL_ID) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
log::warn!("identity: reading people: {e}");
|
||
return;
|
||
}
|
||
};
|
||
|
||
window.set_identity_unassigned(view.unassigned as i32);
|
||
window.set_identity_calibrated(view.calibrated);
|
||
|
||
// Drop portraits for people who no longer exist, so a long session that
|
||
// merges and splits repeatedly does not accumulate them.
|
||
{
|
||
let live: std::collections::HashSet<PersonId> = view.people.iter().map(|p| p.id).collect();
|
||
ctl.covers.borrow_mut().retain(|id, _| live.contains(id));
|
||
}
|
||
|
||
let rows: Vec<IdentityPerson> = view
|
||
.people
|
||
.iter()
|
||
.map(|p| {
|
||
let cover = store.and_then(|store| {
|
||
if let Some(img) = ctl.covers.borrow().get(&p.id) {
|
||
return Some(img.clone());
|
||
}
|
||
let crop = identity::load_cover(cat, store, p.id).ok().flatten()?;
|
||
let img = to_slint_image(crop.width, crop.height, &crop.rgba);
|
||
ctl.covers.borrow_mut().insert(p.id, img.clone());
|
||
Some(img)
|
||
});
|
||
|
||
IdentityPerson {
|
||
id: p.id.0 as i32,
|
||
label: p.display_name().into(),
|
||
unconfirmed: p.is_unconfirmed(),
|
||
confirmed_faces: p.confirmed_faces as i32,
|
||
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.
|
||
if let Some(sel) = ctl.selected.get() {
|
||
if !ctl.people.borrow().iter().any(|p| p.id == sel) {
|
||
ctl.selected.set(None);
|
||
ctl.clear_picks();
|
||
}
|
||
}
|
||
|
||
// So may the person an offer points at — a sync, or a merge performed from
|
||
// the other side. An offer whose target no longer exists would put a button
|
||
// on screen that cannot do anything.
|
||
if let Some(target) = ctl.merge_offer.get() {
|
||
if !ctl.people.borrow().iter().any(|p| p.id == target) {
|
||
clear_merge_offer(window, ctl);
|
||
}
|
||
}
|
||
|
||
match (ctl.selected.get(), store) {
|
||
(Some(person), Some(store)) => {
|
||
let cells = identity::load_faces(cat, store, person).unwrap_or_else(|e| {
|
||
log::warn!("identity: reading faces: {e}");
|
||
Vec::new()
|
||
});
|
||
push_faces(window, ctl, &cells);
|
||
*ctl.faces.borrow_mut() = cells;
|
||
|
||
let name = ctl
|
||
.people
|
||
.borrow()
|
||
.iter()
|
||
.find(|p| p.id == person)
|
||
.map(|p| p.name.clone())
|
||
.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();
|
||
}
|
||
}
|
||
|
||
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||
drop(borrow);
|
||
refresh_coverage(window, catalog, store);
|
||
}
|
||
|
||
/// Run the batch check and put its answer on screen.
|
||
///
|
||
/// Cheap enough to call on every open and after every sweep: two counts and one
|
||
/// indexed scan, no decoding and no inference.
|
||
pub fn refresh_coverage(
|
||
window: &AppWindow,
|
||
catalog: &Rc<RefCell<Option<Catalog>>>,
|
||
store: Option<&ThumbStore>,
|
||
) {
|
||
let borrow = catalog.borrow();
|
||
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
|
||
window.set_identity_coverage(Default::default());
|
||
return;
|
||
};
|
||
match crate::faces::audit(cat, store, MODEL_ID) {
|
||
Ok(a) => {
|
||
window.set_identity_coverage(a.summary().into());
|
||
// Complete means nothing left to index, not "every image has a
|
||
// face": most of a library has none, and that is a finding.
|
||
window.set_identity_coverage_complete(a.coverage.is_complete());
|
||
}
|
||
Err(e) => {
|
||
log::warn!("identity: coverage check: {e}");
|
||
window.set_identity_coverage("coverage unknown".into());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write a name that was typed but never submitted.
|
||
///
|
||
/// The screen's primary action is naming a cluster, and its primary *gesture*
|
||
/// is naming one and moving straight to the next — which is not the gesture
|
||
/// `accepted` reports, because that one requires Enter. Without this, the
|
||
/// common case silently discards the work.
|
||
///
|
||
/// Nothing is offered here. A namesake merge is a question, and asking it about
|
||
/// the person the user has just navigated away from would be answering for a
|
||
/// screen they are no longer looking at; the offer stays on the explicit
|
||
/// submit.
|
||
fn commit_draft(ctl: &IdentityController, catalog: &Rc<RefCell<Option<Catalog>>>) {
|
||
let Some((person, draft)) = ctl.draft.borrow_mut().take() else {
|
||
return;
|
||
};
|
||
// Unchanged text is not an edit. Comparing against what the catalog holds
|
||
// rather than tracking dirtiness keeps this correct when a reload has
|
||
// already written the same name.
|
||
let stored = ctl
|
||
.people
|
||
.borrow()
|
||
.iter()
|
||
.find(|p| p.id == person)
|
||
.map(|p| p.name.clone());
|
||
let Some(stored) = stored else {
|
||
// The person went away while the name was being typed. Writing it
|
||
// would resurrect a row the rail no longer shows.
|
||
return;
|
||
};
|
||
if draft.trim() == stored {
|
||
return;
|
||
}
|
||
let borrow = catalog.borrow();
|
||
let Some(cat) = borrow.as_ref() else { return };
|
||
if let Err(e) = identity::rename(cat, person, &draft) {
|
||
log::warn!("identity: committing a typed name: {e}");
|
||
} else {
|
||
log::info!(
|
||
"identity: {person:?} named {:?} on leaving it",
|
||
draft.trim()
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Put the newly-selected person's name back in the field.
|
||
///
|
||
/// A counter the screen watches, because `Field.text` is two-way bound to its
|
||
/// entry: the first keystroke replaces the binding to `selected-name`, and from
|
||
/// then on the field follows nothing at all.
|
||
fn reset_name_field(window: &AppWindow) {
|
||
window.set_identity_name_revision(window.get_identity_name_revision().wrapping_add(1));
|
||
}
|
||
|
||
/// Take the namesake offer off the screen.
|
||
///
|
||
/// The name is what the strip keys on being visible, so emptying it is what
|
||
/// hides the strip; the target is cleared alongside so a later accept cannot
|
||
/// act on an offer the user can no longer see.
|
||
fn clear_merge_offer(window: &AppWindow, ctl: &IdentityController) {
|
||
ctl.merge_offer.set(None);
|
||
window.set_identity_merge_offer_name(Default::default());
|
||
window.set_identity_merge_offer_faces(0);
|
||
}
|
||
|
||
fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell]) {
|
||
let picked = ctl.picked.borrow();
|
||
let rows: Vec<IdentityFace> = cells
|
||
.iter()
|
||
.map(|c| IdentityFace {
|
||
id: c.face.0 as i32,
|
||
crop: c
|
||
.crop
|
||
.as_ref()
|
||
.map(|p| to_slint_image(p.width, p.height, &p.rgba))
|
||
.unwrap_or_default(),
|
||
has_crop: c.crop.is_some(),
|
||
confirmed: c.confirmed,
|
||
confidence: c.confidence_label().into(),
|
||
crop_px: c.crop_px as i32,
|
||
picked: picked.contains(&c.face),
|
||
})
|
||
.collect();
|
||
window.set_identity_faces(ModelRc::new(VecModel::from(rows)));
|
||
}
|
||
|
||
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
||
buf.make_mut_bytes().copy_from_slice(rgba);
|
||
slint::Image::from_rgba8(buf)
|
||
}
|
||
|
||
/// Where a background sweep reads from: the catalog file and the thumbnail
|
||
/// store directory.
|
||
///
|
||
/// Paths rather than the live handles this screen holds, because the sweep runs
|
||
/// on its own thread and opens its own connection. Two connections to one
|
||
/// SQLite file is the normal arrangement here; sharing a handle across the
|
||
/// boundary would not be.
|
||
/// What the whole-library face pass needs to reach the server.
|
||
///
|
||
/// A connection and not just paths, because the pass fetches its own pixels: an
|
||
/// image with no proxy is the ordinary case, not one to skip (see
|
||
/// `library::spawn_face_sweep`).
|
||
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
|
||
|
||
/// The detector and embedder files, when both are present.
|
||
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
|
||
|
||
/// Attach every Identity callback.
|
||
pub fn wire<S, M, P>(
|
||
window: &AppWindow,
|
||
ctl: Rc<IdentityController>,
|
||
catalog: Rc<RefCell<Option<Catalog>>>,
|
||
activity: Rc<crate::activity::ActivityLog>,
|
||
store: S,
|
||
models: M,
|
||
paths: P,
|
||
) where
|
||
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
||
M: Fn() -> Option<ModelPaths> + 'static,
|
||
P: Fn() -> Option<SweepPaths> + 'static,
|
||
{
|
||
let store: Rc<dyn Fn() -> Option<Rc<ThumbStore>>> = Rc::new(store);
|
||
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
||
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
|
||
|
||
// Re-read everything and redraw. Every mutating callback ends in this
|
||
// rather than patching the model in place: the operations here have
|
||
// second-order effects — a merge empties a person, a split creates one,
|
||
// a rejection changes two counts — and a model patched by hand would
|
||
// drift from the catalog in exactly the cases that matter.
|
||
macro_rules! reload {
|
||
($w:expr, $ctl:expr, $catalog:expr, $store:expr) => {
|
||
refresh(&$w, &$ctl, &$catalog, $store().as_deref())
|
||
};
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let models_present = models.clone();
|
||
window.on_identity_open(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let from_library = w.get_show_library();
|
||
ctl.came_from_library.set(from_library);
|
||
w.set_identity_back_label(if from_library {
|
||
"‹ Library".into()
|
||
} else {
|
||
"‹ Develop".into()
|
||
});
|
||
w.set_show_identity(true);
|
||
// A fact about the filesystem, so it is re-checked on every open
|
||
// rather than cached: the user may have just put the models there.
|
||
w.set_identity_model_missing(models_present().is_none());
|
||
reload!(w, ctl, catalog, store);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
window.on_identity_close(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// The other way out of a half-typed name: leaving the screen
|
||
// entirely. Same rule as changing person — the work was done, so it
|
||
// is written.
|
||
commit_draft(&ctl, &catalog);
|
||
// Back to whichever screen this was opened from. The develop
|
||
// session was never torn down — it was only hidden — so returning
|
||
// to it costs nothing and keeps the photographer's place.
|
||
w.set_show_library(ctl.came_from_library.get());
|
||
w.set_show_identity(false);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_person_picked(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Before the selection moves: a name typed into the field and never
|
||
// submitted belongs to the person being left, and this is the last
|
||
// moment it can be written.
|
||
commit_draft(&ctl, &catalog);
|
||
ctl.selected.set(Some(PersonId(id as u64)));
|
||
ctl.clear_picks();
|
||
// The offer was about the person being navigated away from. Left
|
||
// up, its "Merge" would fold whoever is selected *now*.
|
||
clear_merge_offer(&w, &ctl);
|
||
reload!(w, ctl, catalog, store);
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_rename(move |name| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
// The rename always happens. The merge is a second question, asked
|
||
// afterwards, and answering "keep separate" must leave the name the
|
||
// user typed exactly where they typed it.
|
||
clear_merge_offer(&w, &ctl);
|
||
// Submitted explicitly, so the pending draft is spent — leaving it
|
||
// would rewrite this same name on the way out of the person.
|
||
ctl.draft.borrow_mut().take();
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
if let Err(e) = identity::rename(cat, person, &name) {
|
||
log::warn!("identity: rename: {e}");
|
||
}
|
||
match identity::namesake(cat, person, &name) {
|
||
Ok(Some(other)) => {
|
||
log::info!(
|
||
"identity: {:?} is now called {:?}, which {:?} already is",
|
||
person,
|
||
other.name,
|
||
other.id
|
||
);
|
||
ctl.merge_offer.set(Some(other.id));
|
||
w.set_identity_merge_offer_name(other.name.as_str().into());
|
||
w.set_identity_merge_offer_faces(
|
||
(other.confirmed_faces + other.suggested_faces) as i32,
|
||
);
|
||
}
|
||
Ok(None) => {}
|
||
Err(e) => log::warn!("identity: looking for a namesake: {e}"),
|
||
}
|
||
}
|
||
reload!(w, ctl, catalog, store);
|
||
// `rename` trims; the field should show what was actually stored
|
||
// rather than the spacing the user happened to type.
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
// Accepting the namesake offer. The person the user just named is folded
|
||
// **into** the one that already had the name, not the other way round: the
|
||
// older person is the one other devices have already seen and the one whose
|
||
// confirmations are more likely to be real, and `merge_people` leaves a
|
||
// redirect behind so neither side of a sync resurrects what was merged.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_merge_accept(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let (Some(target), Some(source)) = (ctl.merge_offer.get(), ctl.selected.get()) else {
|
||
return;
|
||
};
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
match identity::merge(cat, target, source) {
|
||
Ok(moved) => {
|
||
log::info!("identity: merged {source:?} into {target:?} ({moved} faces)");
|
||
// Follow the merge. The group the user was looking at
|
||
// no longer exists, and landing on an empty screen
|
||
// after a successful action reads as a failure.
|
||
ctl.selected.set(Some(target));
|
||
ctl.clear_picks();
|
||
}
|
||
Err(e) => log::warn!("identity: merge: {e}"),
|
||
}
|
||
}
|
||
clear_merge_offer(&w, &ctl);
|
||
reload!(w, ctl, catalog, store);
|
||
reset_name_field(&w);
|
||
});
|
||
}
|
||
|
||
// Every keystroke in the name field. Kept rather than written, because a
|
||
// rename per character would be a revision per character in the sidecar.
|
||
{
|
||
let ctl = ctl.clone();
|
||
window.on_identity_name_edited(move |text| {
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
*ctl.draft.borrow_mut() = Some((person, text.to_string()));
|
||
});
|
||
}
|
||
|
||
// Declining it. Nothing to undo — the rename already happened — so this
|
||
// only takes the strip away, and the library keeps two people with one
|
||
// name, which is allowed.
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_identity_merge_decline(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
clear_merge_offer(&w, &ctl);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_confirm_face(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
if let Err(e) = identity::confirm(cat, FaceId(id as u64), person) {
|
||
log::warn!("identity: confirm: {e}");
|
||
}
|
||
}
|
||
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_reject_face(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
if let Err(e) = identity::reject(cat, FaceId(id as u64), person) {
|
||
log::warn!("identity: reject: {e}");
|
||
}
|
||
}
|
||
reload!(w, ctl, catalog, store);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
window.on_identity_toggle_pick(move |id| {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let face = FaceId(id as u64);
|
||
{
|
||
let mut picked = ctl.picked.borrow_mut();
|
||
match picked.iter().position(|&f| f == face) {
|
||
Some(i) => {
|
||
picked.remove(i);
|
||
}
|
||
None => picked.push(face),
|
||
}
|
||
}
|
||
// Only the pick flags changed, so this is the one case that does
|
||
// not re-read the catalog: nothing in it moved.
|
||
push_faces(&w, &ctl, &ctl.faces.borrow());
|
||
w.set_identity_picked(ctl.picked.borrow().len() as i32);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_confirm_all(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
match identity::confirm_all(cat, person) {
|
||
Ok(n) => log::info!("identity: confirmed {n} suggestion(s)"),
|
||
Err(e) => log::warn!("identity: confirm all: {e}"),
|
||
}
|
||
}
|
||
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_split_picked(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
let Some(person) = ctl.selected.get() else {
|
||
return;
|
||
};
|
||
let members: Vec<FaceId> = ctl.picked.borrow().clone();
|
||
if members.is_empty() {
|
||
return;
|
||
}
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
// Unnamed, so the user names it themselves — the same rule the
|
||
// clustering pass follows. A split that guessed a name would
|
||
// be presenting an inference as a fact (FR-CULL-10).
|
||
match identity::split_off(cat, person, &members, "") {
|
||
Ok(new) => log::info!(
|
||
"identity: split {} face(s) onto person {:?}",
|
||
members.len(),
|
||
new
|
||
),
|
||
Err(e) => log::warn!("identity: split: {e}"),
|
||
}
|
||
}
|
||
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();
|
||
let paths = paths.clone();
|
||
window.on_identity_recluster(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// 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;
|
||
}
|
||
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);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
let models = models.clone();
|
||
let paths = paths.clone();
|
||
window.on_identity_index(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if ctl.sweep.borrow().is_some() {
|
||
return;
|
||
}
|
||
let Some((detector, embedder)) = models() else {
|
||
w.set_identity_model_missing(true);
|
||
return;
|
||
};
|
||
let Some((conn, catalog_path, store_dir)) = paths() else {
|
||
return;
|
||
};
|
||
|
||
ctl.progress.set((0, 0));
|
||
ctl.faces_found.set(0);
|
||
*ctl.activity.borrow_mut() =
|
||
Some(activity.begin(crate::activity::Kind::Index, "Indexing faces"));
|
||
*ctl.sweep.borrow_mut() = Some(crate::library::spawn_face_sweep(
|
||
conn,
|
||
catalog_path,
|
||
store_dir,
|
||
detector,
|
||
embedder,
|
||
MODEL_ID.to_string(),
|
||
dr_face::DetectOptions::default(),
|
||
));
|
||
w.set_identity_indexing(true);
|
||
w.set_identity_indexing_status("looking for images to index…".into());
|
||
|
||
// Polled rather than pushed: the worker is a plain thread with an
|
||
// mpsc channel, and a timer on the UI thread keeps every Slint
|
||
// property write where Slint requires it. 250 ms is far shorter
|
||
// than one image takes, so the timer never becomes the bottleneck
|
||
// and never spins on an empty channel for long.
|
||
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(250),
|
||
move || {
|
||
let Some(w) = weak_tick.upgrade() else { return };
|
||
let mut done = false;
|
||
{
|
||
let borrow = ctl_tick.sweep.borrow();
|
||
let Some(rx) = borrow.as_ref() else { return };
|
||
// Drained rather than one per tick: several images can
|
||
// land between ticks, and showing the oldest would make
|
||
// the count visibly lag the work.
|
||
while let Ok(msg) = rx.try_recv() {
|
||
match msg {
|
||
FaceSweepMessage::Total(n) => ctl_tick.progress.set((0, n)),
|
||
FaceSweepMessage::Indexed { faces, .. } => {
|
||
let (seen, total) = ctl_tick.progress.get();
|
||
ctl_tick.progress.set((seen + 1, total));
|
||
ctl_tick.faces_found.set(ctl_tick.faces_found.get() + faces);
|
||
}
|
||
FaceSweepMessage::Finished { .. } => done = true,
|
||
}
|
||
}
|
||
}
|
||
|
||
let (seen, total) = ctl_tick.progress.get();
|
||
w.set_identity_indexing_status(
|
||
format!(
|
||
"indexing {seen}/{total} — {} face(s) found",
|
||
ctl_tick.faces_found.get()
|
||
)
|
||
.into(),
|
||
);
|
||
if let Some(a) = ctl_tick.activity.borrow().as_ref() {
|
||
a.progress(seen, total);
|
||
}
|
||
|
||
if done {
|
||
*ctl_tick.sweep.borrow_mut() = None;
|
||
if let Some(a) = ctl_tick.activity.borrow_mut().take() {
|
||
a.finish(format!(
|
||
"{} face(s) in {seen} image(s)",
|
||
ctl_tick.faces_found.get()
|
||
));
|
||
}
|
||
w.set_identity_indexing(false);
|
||
// Newly indexed faces belong to nobody until they are
|
||
// grouped, so a sweep ending with an unchanged people
|
||
// rail is expected. The coverage line is what shows it
|
||
// worked, and Regroup is the next step.
|
||
refresh(&w, &ctl_tick, &catalog_tick, store_tick().as_deref());
|
||
}
|
||
},
|
||
);
|
||
// A Slint timer stops when it drops, so it has to outlive this
|
||
// callback.
|
||
park_timer(timer);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let ctl = ctl.clone();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_stop_indexing(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
// Dropping the receiver *is* the cancellation: the worker's next
|
||
// send fails and it returns, leaving everything already written
|
||
// written. No flag to fall out of step with the thread.
|
||
*ctl.sweep.borrow_mut() = None;
|
||
if let Some(a) = ctl.activity.borrow_mut().take() {
|
||
a.finish("stopped");
|
||
}
|
||
w.set_identity_indexing(false);
|
||
reload!(w, ctl, catalog, store);
|
||
});
|
||
}
|
||
|
||
{
|
||
let weak = window.as_weak();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_check_coverage(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
refresh_coverage(&w, &catalog, store().as_deref());
|
||
});
|
||
}
|
||
|
||
{
|
||
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();
|
||
let catalog = catalog.clone();
|
||
let store = store.clone();
|
||
window.on_identity_delete_all(move || {
|
||
let Some(w) = weak.upgrade() else { return };
|
||
if let Some(cat) = catalog.borrow().as_ref() {
|
||
match identity::delete_all(cat) {
|
||
Ok(n) => log::info!("identity: deleted {n} face(s) and every person"),
|
||
Err(e) => log::warn!("identity: delete all: {e}"),
|
||
}
|
||
}
|
||
ctl.selected.set(None);
|
||
ctl.clear_picks();
|
||
ctl.covers.borrow_mut().clear();
|
||
reload!(w, ctl, catalog, store);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Keep the running sweep's poll timer alive.
|
||
///
|
||
/// A `slint::Timer` stops when it drops, so the one driving a sweep has to
|
||
/// outlive the callback that started it. Parking it here rather than in the
|
||
/// controller keeps `IdentityController` free of Slint types, which is what
|
||
/// lets it be constructed in a test with no event loop.
|
||
///
|
||
/// One slot: starting a second sweep replaces the first's timer, and by then
|
||
/// the first sweep has already finished or been stopped.
|
||
fn park_timer(timer: slint::Timer) {
|
||
thread_local! {
|
||
static SWEEP_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
||
}
|
||
SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn a_pick_toggles_on_and_off() {
|
||
let ctl = IdentityController::new();
|
||
let f = FaceId(7);
|
||
ctl.picked.borrow_mut().push(f);
|
||
assert_eq!(ctl.picked.borrow().len(), 1);
|
||
let pos = ctl.picked.borrow().iter().position(|&x| x == f);
|
||
assert_eq!(pos, Some(0));
|
||
ctl.picked.borrow_mut().remove(0);
|
||
assert!(ctl.picked.borrow().is_empty());
|
||
}
|
||
|
||
/// Changing person must not carry a stale pick set: a later "Split off"
|
||
/// would otherwise act on faces the user can no longer see.
|
||
#[test]
|
||
fn changing_person_clears_the_pick_set() {
|
||
let ctl = IdentityController::new();
|
||
ctl.picked.borrow_mut().push(FaceId(1));
|
||
ctl.picked.borrow_mut().push(FaceId(2));
|
||
ctl.selected.set(Some(PersonId(1)));
|
||
|
||
ctl.selected.set(Some(PersonId(2)));
|
||
ctl.clear_picks();
|
||
assert!(ctl.picked.borrow().is_empty());
|
||
}
|
||
}
|