An image with no faces in it was indistinguishable from one that had never been looked at, so every landscape, still life and document scan in the library was re-detected on every pass, for ever. In a real library that is most of it: on the 23,527-image test library, 64 of the first 110 images indexed contain no face at all. Schema v9 adds face_index, a run marker per (image, model) carrying the face count and the proxy edge it read. Keyed on the model, so a model change puts every image back in the queue by itself. That makes a coverage figure possible, which is the thing a user actually wants to see. The audit also splits the outstanding set by whether a proxy exists, because 23,417 awaiting a proxy and 110 ready to index are different problems, and telling the user to run indexing again would not fix the first. The Identity screen gains Index faces, Stop, and the coverage line. examples/face_index.rs is the same check and sweep without a window, which is the right shape for an overnight pass. Measured on the real library in release: 3.5 images/second, 110 images and 125 faces in 30 seconds, and a second run correctly finds nothing left to do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
604 lines
22 KiB
Rust
604 lines
22 KiB
Rust
//! TRACES: FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | NFR-SEC-5
|
|
//! The Identity screen's wiring: catalog state onto Slint models and back.
|
|
//!
|
|
//! [`crate::identity`] holds the decisions and is testable without a window;
|
|
//! this file is the part that cannot be, and it is deliberately thin. Anything
|
|
//! here that starts making a judgement belongs over there.
|
|
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::sync::mpsc::Receiver;
|
|
use std::time::Duration;
|
|
|
|
use dr_catalog::faces::{FaceId, PersonId};
|
|
use dr_catalog::Catalog;
|
|
use dr_thumbs::ThumbStore;
|
|
use slint::{ComponentHandle, ModelRc, VecModel};
|
|
|
|
use crate::faces::FaceSweepMessage;
|
|
use crate::identity::{self, FaceCell, PersonRow};
|
|
use crate::{AppWindow, IdentityFace, IdentityPerson};
|
|
|
|
/// Which model's faces the screen is looking at.
|
|
///
|
|
/// A constant for now because exactly one model is supported at a time; it is
|
|
/// named rather than inlined because every query in this file is keyed on it,
|
|
/// and a library indexed across a model change holds faces from both.
|
|
pub const MODEL_ID: &str = "w600k_mbf";
|
|
|
|
/// Screen state that outlives a single callback.
|
|
#[derive(Default)]
|
|
pub struct IdentityController {
|
|
people: RefCell<Vec<PersonRow>>,
|
|
faces: RefCell<Vec<FaceCell>>,
|
|
selected: std::cell::Cell<Option<PersonId>>,
|
|
/// Faces the user has ticked, which is what a split would carry.
|
|
picked: RefCell<Vec<FaceId>>,
|
|
/// The running indexing sweep, if any.
|
|
///
|
|
/// Holding the receiver *is* the cancellation handle: the worker stops when
|
|
/// its send fails, so dropping this is how "Stop" works. No flag to get out
|
|
/// of step with the thread, and everything already written stays written.
|
|
sweep: RefCell<Option<Receiver<FaceSweepMessage>>>,
|
|
/// Images visited so far in the current sweep, and the total it announced.
|
|
progress: std::cell::Cell<(usize, usize)>,
|
|
faces_found: std::cell::Cell<usize>,
|
|
}
|
|
|
|
impl IdentityController {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Clear the multi-select.
|
|
///
|
|
/// Called on every person change: a pick set that survived navigating to
|
|
/// another person would make the next "Split off" act on faces the user
|
|
/// can no longer see, which is the kind of surprise that loses data.
|
|
fn clear_picks(&self) {
|
|
self.picked.borrow_mut().clear();
|
|
}
|
|
}
|
|
|
|
/// Push the people rail and the face grid into the window.
|
|
pub fn refresh(
|
|
window: &AppWindow,
|
|
ctl: &IdentityController,
|
|
catalog: &Rc<RefCell<Option<Catalog>>>,
|
|
store: Option<&ThumbStore>,
|
|
) {
|
|
let borrow = catalog.borrow();
|
|
let Some(cat) = borrow.as_ref() else {
|
|
window.set_identity_people(ModelRc::new(VecModel::from(Vec::<IdentityPerson>::new())));
|
|
window.set_identity_faces(ModelRc::new(VecModel::from(Vec::<IdentityFace>::new())));
|
|
return;
|
|
};
|
|
|
|
let view = match identity::load_people(cat, MODEL_ID) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
log::warn!("identity: reading people: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
window.set_identity_unassigned(view.unassigned as i32);
|
|
window.set_identity_calibrated(view.calibrated);
|
|
|
|
let rows: Vec<IdentityPerson> = view
|
|
.people
|
|
.iter()
|
|
.map(|p| 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,
|
|
cover: slint::Image::default(),
|
|
has_cover: false,
|
|
})
|
|
.collect();
|
|
window.set_identity_people(ModelRc::new(VecModel::from(rows)));
|
|
*ctl.people.borrow_mut() = view.people;
|
|
|
|
// The selected person may have just been merged away or deleted.
|
|
if let Some(sel) = ctl.selected.get() {
|
|
if !ctl.people.borrow().iter().any(|p| p.id == sel) {
|
|
ctl.selected.set(None);
|
|
ctl.clear_picks();
|
|
}
|
|
}
|
|
|
|
match (ctl.selected.get(), store) {
|
|
(Some(person), Some(store)) => {
|
|
let cells = identity::load_faces(cat, store, person, view.calibrated)
|
|
.unwrap_or_else(|e| {
|
|
log::warn!("identity: reading faces: {e}");
|
|
Vec::new()
|
|
});
|
|
push_faces(window, ctl, &cells);
|
|
*ctl.faces.borrow_mut() = cells;
|
|
|
|
let name = ctl
|
|
.people
|
|
.borrow()
|
|
.iter()
|
|
.find(|p| p.id == person)
|
|
.map(|p| p.name.clone())
|
|
.unwrap_or_default();
|
|
window.set_identity_selected(person.0 as i32);
|
|
window.set_identity_selected_name(name.into());
|
|
}
|
|
_ => {
|
|
window.set_identity_selected(-1);
|
|
window.set_identity_selected_name(Default::default());
|
|
window.set_identity_faces(ModelRc::new(VecModel::from(Vec::<IdentityFace>::new())));
|
|
ctl.faces.borrow_mut().clear();
|
|
}
|
|
}
|
|
|
|
window.set_identity_picked(ctl.picked.borrow().len() as i32);
|
|
drop(borrow);
|
|
refresh_coverage(window, catalog, store);
|
|
}
|
|
|
|
/// Run the batch check and put its answer on screen.
|
|
///
|
|
/// Cheap enough to call on every open and after every sweep: two counts and one
|
|
/// indexed scan, no decoding and no inference.
|
|
pub fn refresh_coverage(
|
|
window: &AppWindow,
|
|
catalog: &Rc<RefCell<Option<Catalog>>>,
|
|
store: Option<&ThumbStore>,
|
|
) {
|
|
let borrow = catalog.borrow();
|
|
let (Some(cat), Some(store)) = (borrow.as_ref(), store) else {
|
|
window.set_identity_coverage(Default::default());
|
|
return;
|
|
};
|
|
match crate::faces::audit(cat, store, MODEL_ID) {
|
|
Ok(a) => {
|
|
window.set_identity_coverage(a.summary().into());
|
|
// Complete means nothing left to index, not "every image has a
|
|
// face": most of a library has none, and that is a finding.
|
|
window.set_identity_coverage_complete(a.coverage.is_complete());
|
|
}
|
|
Err(e) => {
|
|
log::warn!("identity: coverage check: {e}");
|
|
window.set_identity_coverage("coverage unknown".into());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_faces(window: &AppWindow, ctl: &IdentityController, cells: &[FaceCell]) {
|
|
let picked = ctl.picked.borrow();
|
|
let rows: Vec<IdentityFace> = cells
|
|
.iter()
|
|
.map(|c| IdentityFace {
|
|
id: c.face.0 as i32,
|
|
crop: c
|
|
.crop
|
|
.as_ref()
|
|
.map(|p| to_slint_image(p.width, p.height, &p.rgba))
|
|
.unwrap_or_default(),
|
|
has_crop: c.crop.is_some(),
|
|
confirmed: c.confirmed,
|
|
confidence: c.confidence_label().into(),
|
|
crop_px: c.crop_px as i32,
|
|
picked: picked.contains(&c.face),
|
|
})
|
|
.collect();
|
|
window.set_identity_faces(ModelRc::new(VecModel::from(rows)));
|
|
}
|
|
|
|
fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image {
|
|
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(width, height);
|
|
buf.make_mut_bytes().copy_from_slice(rgba);
|
|
slint::Image::from_rgba8(buf)
|
|
}
|
|
|
|
/// Where a background sweep reads from: the catalog file and the thumbnail
|
|
/// store directory.
|
|
///
|
|
/// Paths rather than the live handles this screen holds, because the sweep runs
|
|
/// on its own thread and opens its own connection. Two connections to one
|
|
/// SQLite file is the normal arrangement here; sharing a handle across the
|
|
/// boundary would not be.
|
|
pub type SweepPaths = (std::path::PathBuf, std::path::PathBuf);
|
|
|
|
/// The detector and embedder files, when both are present.
|
|
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
|
|
|
|
/// Attach every Identity callback.
|
|
pub fn wire<S, M, P>(
|
|
window: &AppWindow,
|
|
ctl: Rc<IdentityController>,
|
|
catalog: Rc<RefCell<Option<Catalog>>>,
|
|
store: S,
|
|
models: M,
|
|
paths: P,
|
|
) where
|
|
S: Fn() -> Option<Rc<ThumbStore>> + 'static,
|
|
M: Fn() -> Option<ModelPaths> + 'static,
|
|
P: Fn() -> Option<SweepPaths> + 'static,
|
|
{
|
|
let store: Rc<dyn Fn() -> Option<Rc<ThumbStore>>> = Rc::new(store);
|
|
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
|
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
|
|
|
|
// Re-read everything and redraw. Every mutating callback ends in this
|
|
// rather than patching the model in place: the operations here have
|
|
// second-order effects — a merge empties a person, a split creates one,
|
|
// a rejection changes two counts — and a model patched by hand would
|
|
// drift from the catalog in exactly the cases that matter.
|
|
macro_rules! reload {
|
|
($w:expr, $ctl:expr, $catalog:expr, $store:expr) => {
|
|
refresh(&$w, &$ctl, &$catalog, $store().as_deref())
|
|
};
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
let models_present = models.clone();
|
|
window.on_identity_open(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
w.set_show_identity(true);
|
|
// A fact about the filesystem, so it is re-checked on every open
|
|
// rather than cached: the user may have just put the models there.
|
|
w.set_identity_model_missing(models_present().is_none());
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
window.on_identity_close(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
w.set_show_identity(false);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_person_picked(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
ctl.selected.set(Some(PersonId(id as u64)));
|
|
ctl.clear_picks();
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_rename(move |name| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(person) = ctl.selected.get() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
if let Err(e) = identity::rename(cat, person, &name) {
|
|
log::warn!("identity: rename: {e}");
|
|
}
|
|
}
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_confirm_face(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(person) = ctl.selected.get() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
if let Err(e) = identity::confirm(cat, FaceId(id as u64), person) {
|
|
log::warn!("identity: confirm: {e}");
|
|
}
|
|
}
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_reject_face(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(person) = ctl.selected.get() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
if let Err(e) = identity::reject(cat, FaceId(id as u64), person) {
|
|
log::warn!("identity: reject: {e}");
|
|
}
|
|
}
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
window.on_identity_toggle_pick(move |id| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let face = FaceId(id as u64);
|
|
{
|
|
let mut picked = ctl.picked.borrow_mut();
|
|
match picked.iter().position(|&f| f == face) {
|
|
Some(i) => {
|
|
picked.remove(i);
|
|
}
|
|
None => picked.push(face),
|
|
}
|
|
}
|
|
// Only the pick flags changed, so this is the one case that does
|
|
// not re-read the catalog: nothing in it moved.
|
|
push_faces(&w, &ctl, &ctl.faces.borrow());
|
|
w.set_identity_picked(ctl.picked.borrow().len() as i32);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_confirm_all(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(person) = ctl.selected.get() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
match identity::confirm_all(cat, person) {
|
|
Ok(n) => log::info!("identity: confirmed {n} suggestion(s)"),
|
|
Err(e) => log::warn!("identity: confirm all: {e}"),
|
|
}
|
|
}
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_split_picked(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let Some(person) = ctl.selected.get() else { return };
|
|
let members: Vec<FaceId> = ctl.picked.borrow().clone();
|
|
if members.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
// Unnamed, so the user names it themselves — the same rule the
|
|
// clustering pass follows. A split that guessed a name would
|
|
// be presenting an inference as a fact (FR-CULL-10).
|
|
match identity::split_off(cat, person, &members, "") {
|
|
Ok(new) => log::info!(
|
|
"identity: split {} face(s) onto person {:?}",
|
|
members.len(),
|
|
new
|
|
),
|
|
Err(e) => log::warn!("identity: split: {e}"),
|
|
}
|
|
}
|
|
ctl.clear_picks();
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_recluster(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
match crate::faces::recluster(cat, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) {
|
|
Ok((s, c)) => log::info!("identity: {s} suggestion(s), {c} new group(s)"),
|
|
Err(e) => log::warn!("identity: recluster: {e}"),
|
|
}
|
|
}
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
let models = models.clone();
|
|
let paths = paths.clone();
|
|
window.on_identity_index(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if ctl.sweep.borrow().is_some() {
|
|
return;
|
|
}
|
|
let Some((detector, embedder)) = models() else {
|
|
w.set_identity_model_missing(true);
|
|
return;
|
|
};
|
|
let Some((catalog_path, store_dir)) = paths() else {
|
|
return;
|
|
};
|
|
|
|
ctl.progress.set((0, 0));
|
|
ctl.faces_found.set(0);
|
|
*ctl.sweep.borrow_mut() = Some(crate::faces::spawn_face_sweep(
|
|
catalog_path,
|
|
store_dir,
|
|
detector,
|
|
embedder,
|
|
MODEL_ID.to_string(),
|
|
dr_face::DetectOptions::default(),
|
|
));
|
|
w.set_identity_indexing(true);
|
|
w.set_identity_indexing_status("looking for images to index…".into());
|
|
|
|
// Polled rather than pushed: the worker is a plain thread with an
|
|
// mpsc channel, and a timer on the UI thread keeps every Slint
|
|
// property write where Slint requires it. 250 ms is far shorter
|
|
// than one image takes, so the timer never becomes the bottleneck
|
|
// and never spins on an empty channel for long.
|
|
let timer = slint::Timer::default();
|
|
let weak_tick = w.as_weak();
|
|
let ctl_tick = ctl.clone();
|
|
let catalog_tick = catalog.clone();
|
|
let store_tick = store.clone();
|
|
timer.start(
|
|
slint::TimerMode::Repeated,
|
|
Duration::from_millis(250),
|
|
move || {
|
|
let Some(w) = weak_tick.upgrade() else { return };
|
|
let mut done = false;
|
|
{
|
|
let borrow = ctl_tick.sweep.borrow();
|
|
let Some(rx) = borrow.as_ref() else { return };
|
|
// Drained rather than one per tick: several images can
|
|
// land between ticks, and showing the oldest would make
|
|
// the count visibly lag the work.
|
|
while let Ok(msg) = rx.try_recv() {
|
|
match msg {
|
|
FaceSweepMessage::Total(n) => ctl_tick.progress.set((0, n)),
|
|
FaceSweepMessage::Indexed { faces, .. } => {
|
|
let (seen, total) = ctl_tick.progress.get();
|
|
ctl_tick.progress.set((seen + 1, total));
|
|
ctl_tick
|
|
.faces_found
|
|
.set(ctl_tick.faces_found.get() + faces);
|
|
}
|
|
FaceSweepMessage::Finished { .. } => done = true,
|
|
}
|
|
}
|
|
}
|
|
|
|
let (seen, total) = ctl_tick.progress.get();
|
|
w.set_identity_indexing_status(
|
|
format!(
|
|
"indexing {seen}/{total} — {} face(s) found",
|
|
ctl_tick.faces_found.get()
|
|
)
|
|
.into(),
|
|
);
|
|
|
|
if done {
|
|
*ctl_tick.sweep.borrow_mut() = None;
|
|
w.set_identity_indexing(false);
|
|
// Newly indexed faces belong to nobody until they are
|
|
// grouped, so a sweep ending with an unchanged people
|
|
// rail is expected. The coverage line is what shows it
|
|
// worked, and Regroup is the next step.
|
|
refresh(&w, &ctl_tick, &catalog_tick, store_tick().as_deref());
|
|
}
|
|
},
|
|
);
|
|
// A Slint timer stops when it drops, so it has to outlive this
|
|
// callback.
|
|
park_timer(timer);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_stop_indexing(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
// Dropping the receiver *is* the cancellation: the worker's next
|
|
// send fails and it returns, leaving everything already written
|
|
// written. No flag to fall out of step with the thread.
|
|
*ctl.sweep.borrow_mut() = None;
|
|
w.set_identity_indexing(false);
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_check_coverage(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
refresh_coverage(&w, &catalog, store().as_deref());
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let catalog = catalog.clone();
|
|
let store = store.clone();
|
|
window.on_identity_delete_all(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
if let Some(cat) = catalog.borrow().as_ref() {
|
|
match identity::delete_all(cat) {
|
|
Ok(n) => log::info!("identity: deleted {n} face(s) and every person"),
|
|
Err(e) => log::warn!("identity: delete all: {e}"),
|
|
}
|
|
}
|
|
ctl.selected.set(None);
|
|
ctl.clear_picks();
|
|
reload!(w, ctl, catalog, store);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Keep the running sweep's poll timer alive.
|
|
///
|
|
/// A `slint::Timer` stops when it drops, so the one driving a sweep has to
|
|
/// outlive the callback that started it. Parking it here rather than in the
|
|
/// controller keeps `IdentityController` free of Slint types, which is what
|
|
/// lets it be constructed in a test with no event loop.
|
|
///
|
|
/// One slot: starting a second sweep replaces the first's timer, and by then
|
|
/// the first sweep has already finished or been stopped.
|
|
fn park_timer(timer: slint::Timer) {
|
|
thread_local! {
|
|
static SWEEP_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
|
|
}
|
|
SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_pick_toggles_on_and_off() {
|
|
let ctl = IdentityController::new();
|
|
let f = FaceId(7);
|
|
ctl.picked.borrow_mut().push(f);
|
|
assert_eq!(ctl.picked.borrow().len(), 1);
|
|
let pos = ctl.picked.borrow().iter().position(|&x| x == f);
|
|
assert_eq!(pos, Some(0));
|
|
ctl.picked.borrow_mut().remove(0);
|
|
assert!(ctl.picked.borrow().is_empty());
|
|
}
|
|
|
|
/// Changing person must not carry a stale pick set: a later "Split off"
|
|
/// would otherwise act on faces the user can no longer see.
|
|
#[test]
|
|
fn changing_person_clears_the_pick_set() {
|
|
let ctl = IdentityController::new();
|
|
ctl.picked.borrow_mut().push(FaceId(1));
|
|
ctl.picked.borrow_mut().push(FaceId(2));
|
|
ctl.selected.set(Some(PersonId(1)));
|
|
|
|
ctl.selected.set(Some(PersonId(2)));
|
|
ctl.clear_picks();
|
|
assert!(ctl.picked.borrow().is_empty());
|
|
}
|
|
}
|
|
|