Files
DarkRoom/ui/dr-ui/src/identity_ui.rs
T
dtourolle 4f31123b0c Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small
faces in every group shot, and a dog embedded a dozen times. Which
trade is right depends on the machine doing the sweep — a desktop left
overnight and a tablet on a battery want different answers — so the
detector is now a per-device setting, Fast / Balanced / Thorough on
the settings page beside the indexing button, persisted with the rest
of the settings file.

A detector is half of a model id. Every face, marker, shard and
calibration is keyed on faces.model_id precisely so that a model change
is a new id and a re-index rather than a silent change under existing
data, and a detector change is a model change: it decides which faces
exist and where the landmarks that align them land. So each choice
names its own pipeline. 500M keeps the bare "w600k_mbf" every existing
library was written under, so an upgrade disturbs nothing; the others
are qualified. Choosing one restarts coverage from zero under the new
id, the sweep re-detects, confirmed names carry across by box overlap,
and the sync shards are keyed by the same id so a peer on another
setting neither adopts nor pollutes them. The library controller
carries the id into the sync the same way it carries the cache budget,
because the sync starts from places that have no settings in reach.

All three shape-fixed exports ship — APK, Arch, Flatpak — since a
tablet has no other way to obtain the one it was not installed with;
the APK grows by twenty megabytes for the choice.
2026-09-11 22:12:53 +02:00

1378 lines
58 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, Model as _, 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.
///
/// Every query in this file is keyed on it, and a library indexed across a
/// detector change holds faces from both pipelines: the screen shows the one
/// the settings page currently names (`FaceDetector::model_id`), read at each
/// use rather than captured once, because the page can change it while the
/// screen is open.
pub fn model_id(settings: &crate::settings_ui::SettingsController) -> String {
settings.snapshot().faces.detector.model_id().to_string()
}
/// 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>,
/// Images the running sweep could not get through.
///
/// On screen because a pass that fails everything used to be
/// indistinguishable from one that succeeded at everything: both ended
/// with a tidy "0 face(s) in 0 image(s)".
sweep_failed: 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>>>,
/// The running read-only preview of the grouping dials, if any.
///
/// Separate from `regroup` because the two are allowed to be about
/// different things at once and neither blocks the other: a preview writes
/// nothing, so there is nothing for a concurrent pass to corrupt.
preview: RefCell<Option<Receiver<crate::faces::PreviewMessage>>>,
/// 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();
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Drop the decoded rail portraits.
///
/// The one in-memory image cache in this crate that is unbounded by
/// anything but the library: one decoded portrait per person, kept for as
/// long as the person exists. On a library with a few hundred named people
/// that is worth tens of megabytes of nothing but a saved decode.
///
/// Costless to lose. The next reload hands whatever it does not find to
/// `fill_covers`, so the only consequence is the JPEG decode this cache
/// exists to skip — paid off the blocking path, a slice at a time.
pub fn clear_covers(&self) {
self.covers.borrow_mut().clear();
}
}
/// Push the people rail and the face grid into the window.
///
/// **Draws with the portraits it already has and cuts the rest afterwards.**
/// Cutting one costs a JPEG decode, and where the face predates the stored-crop
/// column it costs a whole 1024px proxy decode — 3.2 seconds of them on the
/// reference library, every one of which used to be spent between the click on
/// "Identity" and the screen appearing. `fill_covers` does that work a slice at
/// a time, in rail order, so the rail is on screen immediately and fills in
/// from the top.
pub fn refresh(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: Option<Rc<ThumbStore>>,
model_id: &str,
) {
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));
}
// Filtered here rather than by the row hiding itself, which is what the
// rail used to do. A row that collapses to 0px is a height that reads the
// model, and the `ListView` drawing the rail cannot virtualise past one —
// see the rule in `widgets.slint`. The toggle reloads either way, so this
// costs a pass over a list that was already in hand.
let show_ignored = ctl.show_ignored.get();
// Whoever the rail is missing a portrait for, as (row, person) in rail
// order — which is the order `fill_covers` works in, so the rows the user
// is looking at are cut first.
let mut pending: Vec<(usize, PersonId)> = Vec::new();
let rows: Vec<IdentityPerson> = view
.people
.iter()
.filter(|p| show_ignored || !p.ignored)
.enumerate()
.map(|(row, p)| {
// The cache only. Cutting a portrait here is what used to hold the
// screen shut; anything not already in hand is left to `fill_covers`.
let cover = ctl.covers.borrow().get(&p.id).cloned();
if cover.is_none() && store.is_some() {
pending.push((row, p.id));
}
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.as_deref()) {
(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.as_deref(), model_id);
// Last, so a portrait cannot delay anything above it.
if let Some(store) = store {
fill_covers(window, ctl, catalog, store, pending);
}
}
/// How long one slice of portrait-cutting may hold the UI thread.
///
/// Under half a 60Hz frame. The unit of work is one portrait and it is not
/// divisible, so a slice overruns by whatever the last one cost — a stored crop
/// is well under a millisecond, and the proxy fallback is about three and a
/// half, which is a frame that renders late rather than a frame that is missed.
const COVER_SLICE: Duration = Duration::from_millis(8);
/// Cut the rail portraits that were not already cached, a slice per tick.
///
/// # Why a timer and not a thread
///
/// The work is a `Catalog` query and a decode against a `ThumbStore`, and both
/// handles live on this thread — a worker would need its own connection to the
/// same SQLite file, which is what the sweep and the regrouping pass do because
/// they run for minutes and would otherwise be unbounded. This is seconds of
/// small, independent pieces, so slicing it is the cheaper answer to the same
/// question: nothing here ever holds the thread for longer than a frame.
///
/// # Ordering
///
/// `pending` arrives in rail order, and the rail is sorted by confirmed faces,
/// so the portraits the user is looking at are cut first and the tail fills in
/// behind them. With the rail virtualised, the rows past the fold are not even
/// drawn until they are scrolled to.
///
/// # Why it patches rather than reloads
///
/// A portrait changes one cell. Calling `refresh` for it would re-read the
/// catalog and rebuild the model on every tick, and the model being replaced
/// underneath the `ListView` is exactly what the slicing exists to avoid.
fn fill_covers(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: Rc<ThumbStore>,
pending: Vec<(usize, PersonId)>,
) {
if pending.is_empty() {
// Parking `None` stops whatever the last refresh left running: its
// pending list is about a model that no longer exists.
park_cover_timer(None);
return;
}
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
// Where the last tick got to. The list is only read forwards.
let mut next = 0usize;
timer.start(slint::TimerMode::Repeated, COVER_SLICE, move || {
let Some(w) = weak.upgrade() else { return };
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
park_cover_timer(None);
return;
};
let rows = w.get_identity_people();
let started = std::time::Instant::now();
while next < pending.len() && started.elapsed() < COVER_SLICE {
let (row, person) = pending[next];
next += 1;
// The row index came from the model this fill was started for, and
// a reload between ticks replaces that model. Checked rather than
// trusted, because the failure it prevents is silent and wrong: a
// face drawn beside somebody else's name. A mismatch — or a model
// too short to hold the row — means this fill is about a rail that
// no longer exists, and the reload that replaced it started its own.
let Some(mut cell) = rows.row_data(row).filter(|c| c.id == person.0 as i32) else {
park_cover_timer(None);
return;
};
let Ok(Some(crop)) = identity::load_cover(cat, &store, person) else {
continue;
};
let img = to_slint_image(crop.width, crop.height, &crop.rgba);
ctl.covers.borrow_mut().insert(person, img.clone());
cell.has_cover = true;
cell.cover = img;
rows.set_row_data(row, cell);
}
if next >= pending.len() {
park_cover_timer(None);
}
});
park_cover_timer(Some(timer));
}
/// 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>,
model_id: &str,
) {
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,
quality: c.quality_label().into(),
in_gallery: c.in_gallery(),
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);
/// Put the grouping dials on the screen from the settings record.
///
/// Read back out of the controller rather than echoed from the callback's
/// argument, because `Settings::sanitise` may have moved the number: a slider
/// showing 40% while the file held the clamped 50% would be a control that
/// silently disagreed with what the next Regroup was going to do.
fn push_grouping(window: &AppWindow, settings: &crate::settings_ui::SettingsController) {
let s = settings.snapshot();
window.set_identity_merge_probability(s.faces.merge_probability * 100.0);
window.set_identity_min_group_size(s.faces.min_group_size as i32);
}
/// Attach every Identity callback.
///
/// Eight arguments because the screen has eight distinct dependencies and no
/// two of them belong together: three ways of reaching the library, two
/// controllers, the window, the activity log and the settings record. Bundling
/// them into a parameter struct would name a thing that does not exist — the
/// same reason every other `wire` in this file's neighbourhood carries the
/// allow.
#[allow(clippy::too_many_arguments)]
pub fn wire<S, M, P>(
window: &AppWindow,
ctl: Rc<IdentityController>,
catalog: Rc<RefCell<Option<Catalog>>>,
activity: Rc<crate::activity::ActivityLog>,
settings: Rc<crate::settings_ui::SettingsController>,
store: S,
models: M,
paths: P,
// TRACES: FR-CULL-8
// `None` on a build with no adapter. Indexing needs a native render and a
// native render needs the GPU, so the button reports that the same way it
// reports a missing model rather than starting a pass that cannot produce
// anything.
gpu: Option<dr_gpu::GpuContext>,
) where
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
M: Fn() -> Option<ModelPaths> + 'static,
P: Fn() -> Option<SweepPaths> + 'static,
{
let gpu = Rc::new(gpu);
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);
// The dials start where the settings file left them, once, rather than on
// every open: the screen writes them back through the two callbacks below,
// and re-pushing them mid-drag would fight the slider's own live value.
push_grouping(window, &settings);
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_merge_probability_changed(move |percent| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.merge_probability = percent / 100.0);
push_grouping(&w, &settings);
// The answer on screen was about the old value. Left there it would
// be read as a description of the new one, which is worse than
// having no preview at all.
w.set_identity_grouping_preview(Default::default());
});
}
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_min_group_size_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.min_group_size = n.max(0) as u32);
push_grouping(&w, &settings);
w.set_identity_grouping_preview(Default::default());
});
}
// 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, $settings:expr) => {
refresh(&$w, &$ctl, &$catalog, $store(), &model_id(&$settings))
};
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
{
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();
let settings = settings.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, settings);
reset_name_field(&w);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
// `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();
let settings = settings.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, settings);
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();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
{
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();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let paths = paths.clone();
let settings = settings.clone();
window.on_identity_preview_grouping(move || {
let Some(w) = weak.upgrade() else { return };
// One at a time, like every other pass here. Two previews would
// race to write the same line and the loser's answer would win.
if ctl.preview.borrow().is_some() {
return;
}
let Some((_, catalog_path, _)) = paths() else {
return;
};
w.set_identity_previewing(true);
*ctl.preview.borrow_mut() = Some(crate::faces::spawn_grouping_preview(
catalog_path,
model_id(&settings),
settings.snapshot().faces,
));
let timer = slint::Timer::default();
let weak_tick = w.as_weak();
let ctl_tick = ctl.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.preview.borrow();
let Some(rx) = borrow.as_ref() else { return };
while let Ok(msg) = rx.try_recv() {
match msg {
crate::faces::PreviewMessage::Ready(p) => {
w.set_identity_grouping_preview(
identity::preview_label(&p).into(),
);
done = true;
}
crate::faces::PreviewMessage::Failed(e) => {
log::warn!("identity: preview: {e}");
w.set_identity_grouping_preview(
format!("could not work it out: {e}").into(),
);
done = true;
}
}
}
}
if done {
*ctl_tick.preview.borrow_mut() = None;
w.set_identity_previewing(false);
}
},
);
park_preview_timer(timer);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let paths = paths.clone();
let settings_for_regroup = settings.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(&settings_for_regroup),
settings_for_regroup.snapshot().faces,
));
// 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();
let settings_tick = settings_for_regroup.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(),
&model_id(&settings_tick),
);
}
},
);
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();
let gpu = gpu.clone();
let settings_for_sweep = settings.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;
};
// Same reporting path as a missing model: both mean the pass
// cannot run, and both are states a fresh install can be in.
let Some(gpu) = gpu.as_ref().clone() else {
w.set_identity_model_missing(true);
return;
};
ctl.progress.set((0, 0));
ctl.faces_found.set(0);
ctl.sweep_failed.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(&settings_for_sweep),
dr_face::DetectOptions::default(),
gpu,
));
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();
let settings_tick = settings_for_sweep.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::Failed { images } => {
// Advances the same counter. Work that
// failed is still work the pass got
// through, and a bar that only moves on
// success reports a run of pure failure as
// no progress at all.
let (seen, total) = ctl_tick.progress.get();
ctl_tick.progress.set((seen + images, total));
ctl_tick
.sweep_failed
.set(ctl_tick.sweep_failed.get() + images);
}
FaceSweepMessage::Finished { failed, .. } => {
// The sweep's own tallies win over the
// running ones: a cancelled or offline
// pass can end without every batch having
// been reported.
ctl_tick.sweep_failed.set(failed);
done = true;
}
}
}
}
let (seen, total) = ctl_tick.progress.get();
let mut status = format!(
"indexing {seen}/{total} — {} face(s) found",
ctl_tick.faces_found.get()
);
if ctl_tick.sweep_failed.get() > 0 {
status.push_str(&format!(", {} failed", ctl_tick.sweep_failed.get()));
}
w.set_identity_indexing_status(status.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() {
// Say what did not work. A pass that failed every
// image used to finish with the same sentence as
// one that succeeded at every image, which is how
// 169 consecutive failures went unnoticed for a
// day.
let mut msg = format!(
"{} face(s) in {seen} image(s)",
ctl_tick.faces_found.get()
);
if ctl_tick.sweep_failed.get() > 0 {
msg.push_str(&format!(
", {} could not be read",
ctl_tick.sweep_failed.get()
));
}
a.finish(msg);
}
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(),
&model_id(&settings_tick),
);
}
},
);
// 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();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.clone();
window.on_identity_check_coverage(move || {
let Some(w) = weak.upgrade() else { return };
refresh_coverage(&w, &catalog, store().as_deref(), &model_id(&settings));
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.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, settings);
});
}
}
/// 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));
}
/// Keep the grouping preview's poll timer alive.
///
/// **A slot of its own, and that is the whole point.** A preview and a
/// regrouping pass are allowed to be in flight together — the preview writes
/// nothing — so parking both in [`park_timer`]'s single slot would have the
/// second to start drop the first's timer. The visible symptom would be a
/// Regroup that finished on its worker and never told the screen: the button
/// stuck on "Regrouping…" for the life of the window.
fn park_preview_timer(timer: slint::Timer) {
thread_local! {
static PREVIEW_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
}
/// Keep the portrait fill's slice timer alive — and stop it.
///
/// A third slot, because a fill runs at the same time as anything else the
/// screen is doing: it starts on every reload, and a reload is how a sweep and
/// a regrouping pass report progress.
///
/// Takes an `Option` where the others do not, because this is the one that ends
/// on its own. `None` drops the parked timer, which stops it — including from
/// inside its own callback, which is where the last slice does it. Slint
/// supports that directly: a timer removed while its callback is running is
/// marked and dropped afterwards.
fn park_cover_timer(timer: Option<slint::Timer>) {
thread_local! {
static COVER_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
COVER_TIMER.with(|slot| *slot.borrow_mut() = 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());
}
}