Files
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00

1684 lines
69 KiB
Rust
Raw Permalink 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, View};
/// 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 {
crate::inference::model_id(settings.snapshot().faces.detector).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();
}
}
/// What a reload has to re-read, named by what just changed.
///
/// The coverage line is the expensive half of a redraw: it lists every
/// repair's outstanding images to count them, which is several scans of the
/// whole `images` table (`crate::repairs::counts`). A confirm, a reject, a
/// rename or a merge moves faces between people and cannot change how many
/// images have been indexed, so a redraw for one of those must not pay for
/// it — that was 200 ms of the half-second every click on the face grid used
/// to cost on the reference library.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Changed {
/// Who the faces belong to. The rail and the grid are re-read; the
/// coverage line is left as it was.
Identities,
/// Which faces exist: a sweep finished or was stopped, the face data was
/// deleted, the screen was opened onto a catalog another device may have
/// indexed. Everything is re-read, the coverage line included.
Library,
}
/// 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,
eyes: bool,
changed: Changed,
) {
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)) => {
// The crops the grid is showing now, handed over to be reused
// rather than decoded again — see `load_faces`. Drained, not
// cloned: a crop is 64 KB of pixels and the largest groups hold
// hundreds.
let cut = ctl
.faces
.borrow_mut()
.drain(..)
.filter_map(|c| Some((c.face, c.crop?)))
.collect();
let cells = identity::load_faces(cat, store, person, cut).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);
if changed == Changed::Library {
refresh_coverage(window, catalog, store.as_deref(), model_id, eyes);
}
// 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.
/// `eyes` is whether this device has the eye models: with them, faces with
/// no eye reading are work the job has left (`crate::faces::audit`), and
/// the registry the count is taken from is the one the job would run.
pub fn refresh_coverage(
window: &AppWindow,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: Option<&ThumbStore>,
model_id: &str,
eyes: bool,
) {
let borrow = catalog.borrow();
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
window.set_identity_coverage(Default::default());
return;
};
let detector = dr_types::FaceDetector::for_model_id(model_id).unwrap_or_default();
let repairs = crate::repairs::registry(
crate::repairs::Scope::Outstanding,
model_id,
detector,
crate::repairs::Capabilities {
gpu: true,
face_models: true,
eye_models: eyes,
},
);
match crate::faces::audit(cat, store, model_id, &repairs) {
Ok(a) => {
window.set_identity_coverage(a.summary().into());
// Complete means nothing left to index or measure, not "every
// image has a face": most of a library has none, and that is a
// finding.
window.set_identity_coverage_complete(a.is_complete());
// Detection done, readings outstanding: the button names the
// pass it will run rather than promising to index.
window.set_identity_coverage_read_only(a.coverage.is_complete() && a.has_repairs());
}
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(),
eyes: c.eyes_label().into(),
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
/// `repairs::spawn`).
pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathBuf);
/// The detector and embedder files, when both are present — and the eye
/// models beside them, when those are.
pub type ModelPaths = crate::library::FaceModelPaths;
/// 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);
}
macro_rules! reload {
($w:expr, $ctl:expr, $catalog:expr, $store:expr, $settings:expr, $eyes:expr, $changed:ident) => {
refresh(
&$w,
&$ctl,
&$catalog,
$store(),
&model_id(&$settings),
$eyes(),
Changed::$changed,
)
};
}
/// 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);
// 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.
// Whether the eye models are on this machine, asked each time rather
// than once: it is two `is_file` checks, and a user who drops the files
// in while the app is open should see the faces become work to measure.
let eyes_available: Rc<dyn Fn() -> bool> = {
let models = models.clone();
Rc::new(move || models().is_some_and(|m| m.eyes.is_some()))
};
// The availability closure is an argument rather than named in the
// body: macro hygiene would bind the name to this scope's `Rc`, which the
// first `move` closure would then take with it.
// The last argument is a `Changed` variant, named bare so the call
// stays on one line.
wire_dials(window, &settings);
wire_navigation(
window,
&ctl,
&catalog,
&store,
&models,
&settings,
&eyes_available,
);
wire_rename_and_merge(window, &ctl, &catalog, &store, &settings, &eyes_available);
wire_face_actions(window, &ctl, &catalog, &store, &settings, &eyes_available);
wire_grouping_preview(window, &ctl, &paths, &settings);
wire_recluster(
window,
&ctl,
&catalog,
&store,
&paths,
&settings,
&eyes_available,
);
wire_indexing(
window,
&ctl,
&catalog,
&activity,
&store,
&models,
&paths,
&gpu,
&settings,
&eyes_available,
);
wire_coverage_and_ignore(window, &ctl, &catalog, &store, &settings, &eyes_available);
}
/// The dials.
///
/// 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.
fn wire_dials(window: &AppWindow, settings: &Rc<crate::settings_ui::SettingsController>) {
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());
});
}
}
/// Opening and closing the screen, and switching which person is shown.
fn wire_navigation(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
models: &Rc<dyn Fn() -> Option<ModelPaths>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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_active_view() == View::Library;
ctl.came_from_library.set(from_library);
w.set_identity_back_label(if from_library {
"‹ Library".into()
} else {
"‹ Develop".into()
});
w.set_active_view(View::Identity);
// 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, eyes_available, Library);
});
}
{
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_active_view(if ctl.came_from_library.get() {
View::Library
} else {
View::Develop
});
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
reset_name_field(&w);
});
}
// TRACES: FR-UI-5
// Up and down the rail from the keyboard. The rail's order is the model's
// order, so the neighbour is found there and then picked exactly as a
// click on it would pick it — one path for selecting a person, whichever
// way it was asked for. Stops at either end rather than wrapping: a rail
// of two thousand people that jumped from the last to the first would
// lose the user's place.
{
let weak = window.as_weak();
window.on_identity_person_stepped(move |delta| {
let Some(w) = weak.upgrade() else { return };
let people = w.get_identity_people();
let ids: Vec<i32> = people.iter().map(|p| p.id).collect();
if let Some(id) = stepped_person(&ids, w.get_identity_selected(), delta) {
w.invoke_identity_person_picked(id);
}
});
}
}
/// The person `delta` places along the rail from `selected`, or `None` where
/// that is off either end or the rail is empty. With nobody selected, the
/// first key down the rail takes the first person and up takes the last.
fn stepped_person(ids: &[i32], selected: i32, delta: i32) -> Option<i32> {
let last = ids.len().checked_sub(1)?;
let next = match ids.iter().position(|id| *id == selected) {
Some(at) => {
let to = at as i64 + delta.signum() as i64;
if to < 0 || to > last as i64 {
return None;
}
to as usize
}
None if delta < 0 => last,
None => 0,
};
ids.get(next).copied()
}
/// Renaming a person, and the namesake merge offer a rename can raise.
fn wire_rename_and_merge(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
// `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 eyes_available = eyes_available.clone();
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, eyes_available, Identities);
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);
});
}
}
/// The grid: picking, confirming, rejecting and splitting off faces.
fn wire_face_actions(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
{
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 eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
}
/// A dry run of the grouping dials, without writing anything.
fn wire_grouping_preview(
window: &AppWindow,
ctl: &Rc<IdentityController>,
paths: &Rc<dyn Fn() -> Option<SweepPaths>>,
settings: &Rc<crate::settings_ui::SettingsController>,
) {
{
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);
});
}
}
/// Regroup: rerun clustering over faces already indexed.
fn wire_recluster(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
paths: &Rc<dyn Fn() -> Option<SweepPaths>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
{
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();
let eyes_available = eyes_available.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();
let eyes_tick = eyes_available.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),
eyes_tick(),
Changed::Identities,
);
}
},
);
park_timer(timer);
});
}
}
#[allow(clippy::too_many_arguments)]
/// One launcher behind two buttons. "Index faces" and "Re-index every
/// face" differ only in which images the pass visits (`FaceSweepScope`);
/// the models, the progress, the activity row and the Stop button are the
/// same, and a second copy of this closure would be a second place for
/// them to disagree.
fn wire_indexing(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
activity: &Rc<crate::activity::ActivityLog>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
models: &Rc<dyn Fn() -> Option<ModelPaths>>,
paths: &Rc<dyn Fn() -> Option<SweepPaths>>,
gpu: &Rc<Option<dr_gpu::GpuContext>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
let launch: Rc<dyn Fn(crate::repairs::Scope)> = {
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();
let eyes_available = eyes_available.clone();
let activity = activity.clone();
Rc::new(move |scope: crate::repairs::Scope| {
let Some(w) = weak.upgrade() else { return };
if ctl.sweep.borrow().is_some() {
return;
}
let Some(models) = 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;
};
let reindex = scope == crate::repairs::Scope::Reindex;
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,
if reindex {
"Re-indexing faces"
} else {
"Indexing faces"
},
));
*ctl.sweep.borrow_mut() = Some(crate::repairs::spawn(
conn,
catalog_path,
store_dir,
Some(models),
model_id(&settings_for_sweep),
settings_for_sweep.snapshot().faces.detector,
scope,
dr_face::DetectOptions::default(),
Some(gpu),
));
w.set_identity_indexing(true);
w.set_identity_indexing_status(
if reindex {
"looking for images to detect again…"
} else {
"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();
let eyes_tick = eyes_available.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),
eyes_tick(),
Changed::Library,
);
}
},
);
// A Slint timer stops when it drops, so it has to outlive this
// callback.
park_timer(timer);
})
};
{
let launch = launch.clone();
window.on_identity_index(move || launch(crate::repairs::Scope::Outstanding));
}
{
// TRACES: FR-CULL-8 | FR-CULL-10
let launch = launch.clone();
window.on_identity_reindex(move || launch(crate::repairs::Scope::Reindex));
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Library);
});
}
}
/// Coverage, setting a person aside, and clearing every person and face.
fn wire_coverage_and_ignore(
window: &AppWindow,
ctl: &Rc<IdentityController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
store: &Rc<dyn Fn() -> Option<Rc<ThumbStore>>>,
settings: &Rc<crate::settings_ui::SettingsController>,
eyes_available: &Rc<dyn Fn() -> bool>,
) {
{
let weak = window.as_weak();
let catalog = catalog.clone();
let store = store.clone();
let settings = settings.clone();
let eyes_available = eyes_available.clone();
window.on_identity_check_coverage(move || {
let Some(w) = weak.upgrade() else { return };
refresh_coverage(
&w,
&catalog,
store().as_deref(),
&model_id(&settings),
eyes_available(),
);
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Identities);
});
}
{
let weak = window.as_weak();
let eyes_available = eyes_available.clone();
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, eyes_available, Library);
});
}
}
/// 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::stepped_person;
/// TRACES: FR-UI-5
#[test]
fn stepping_the_rail_stops_at_either_end() {
let rail = [7, 3, 9];
assert_eq!(stepped_person(&rail, 3, 1), Some(9));
assert_eq!(stepped_person(&rail, 3, -1), Some(7));
assert_eq!(stepped_person(&rail, 9, 1), None);
assert_eq!(stepped_person(&rail, 7, -1), None);
assert_eq!(stepped_person(&rail, -1, 1), Some(7));
assert_eq!(stepped_person(&rail, -1, -1), Some(9));
assert_eq!(stepped_person(&[], -1, 1), None);
}
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());
}
}