//! 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>, faces: RefCell>, selected: std::cell::Cell>, /// Faces the user has ticked, which is what a split would carry. picked: RefCell>, /// 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>>, /// Images visited so far in the current sweep, and the total it announced. progress: std::cell::Cell<(usize, usize)>, faces_found: std::cell::Cell, /// 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, /// 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, /// 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>, /// 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>, /// 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>, /// 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>>, /// 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>>, /// Whether the rail is showing the people the user has set aside. show_ignored: std::cell::Cell, /// 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>, } 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, catalog: &Rc>>, store: Option>, 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::::new()))); window.set_identity_faces(ModelRc::new(VecModel::from(Vec::::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 = 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 = 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::::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, catalog: &Rc>>, store: Rc, 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>>, 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>>) { 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 = 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::::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( window: &AppWindow, ctl: Rc, catalog: Rc>>, activity: Rc, settings: Rc, 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, ) where S: Fn() -> Option> + 'static, M: Fn() -> Option + 'static, P: Fn() -> Option + 'static, { let gpu = Rc::new(gpu); let store: Rc Option>> = Rc::new(store); let models: Rc Option> = Rc::new(models); let paths: Rc Option> = 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 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) { 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, catalog: &Rc>>, store: &Rc Option>>, models: &Rc Option>, settings: &Rc, eyes_available: &Rc 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); }); } } /// Renaming a person, and the namesake merge offer a rename can raise. fn wire_rename_and_merge( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, store: &Rc Option>>, settings: &Rc, eyes_available: &Rc 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, catalog: &Rc>>, store: &Rc Option>>, settings: &Rc, eyes_available: &Rc 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 = 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, paths: &Rc Option>, settings: &Rc, ) { { 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, catalog: &Rc>>, store: &Rc Option>>, paths: &Rc Option>, settings: &Rc, eyes_available: &Rc 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, catalog: &Rc>>, activity: &Rc, store: &Rc Option>>, models: &Rc Option>, paths: &Rc Option>, gpu: &Rc>, settings: &Rc, eyes_available: &Rc bool>, ) { let launch: Rc = { 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, catalog: &Rc>>, store: &Rc Option>>, settings: &Rc, eyes_available: &Rc 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> = 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> = 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) { thread_local! { static COVER_TIMER: RefCell> = 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()); } }