Files
DarkRoom/ui/dr-ui/src/identity_ui.rs
T
dtourolleandClaude Opus 5 25c88d9dbd Start face indexing from Settings
Beside the thumbnail sweep, because it is the same kind of thing: a job
that runs for an hour, is asked for once, and reports into the activity
list above it. It is also downstream of that sweep -- detection reads the
proxies it builds -- so the two belong in that order, and the coverage
line says how many images are waiting on a proxy rather than only how
many are left to index.

The button drives the Identity Manager's own state rather than a second
copy, so it cannot disagree with that screen about whether a pass is
running, and either place can start or stop it.

The pass now opens an activity row. The caption promises progress will
appear in the list above, and without a row it would not: the button
would be the only sign anything was happening, invisible from every
other screen.

Coverage is read when the Settings page opens. The figures live in the
catalog and this page deliberately holds no session, so they arrive
through a closure rather than being kept current -- they are only ever
looked at while the page is on screen, and the check is two counts and an
indexed scan.

Also adds DARKROOM_NO_SYNC. Redirecting XDG_DATA_HOME isolates a test
launch's catalog and thumbnails but not its server, and I found that out
by pushing a test catalog over the live one. The guard sits in
start_derived_sync rather than at its three call sites, because the sweep
firing a sync is correct and a flag checked in three places is one that
gets missed in a fourth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:22:43 +02:00

675 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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>>,
/// 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(),
}
})
.collect();
window.set_identity_people(ModelRc::new(VecModel::from(rows)));
*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();
}
}
match (ctl.selected.get(), store) {
(Some(person), Some(store)) => {
let cells = identity::load_faces(cat, store, person, view.calibrated)
.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(-1);
window.set_identity_selected_name(Default::default());
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());
}
}
}
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.
pub type SweepPaths = (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();
window.on_identity_close(move || {
let Some(w) = weak.upgrade() else { return };
// 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 };
ctl.selected.set(Some(PersonId(id as u64)));
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_rename(move |name| {
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::rename(cat, person, &name) {
log::warn!("identity: rename: {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_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();
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}"),
}
}
reload!(w, ctl, catalog, store);
});
}
{
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((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::faces::spawn_face_sweep(
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_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());
}
}