Merge: face grouping the photographer can tune, people on the filter bar, and a tap that means it

Four fixes to the identity work, and one to the grid.

The name field lets the keyboard go when a name is finished, instead of
leaving it up over the faces the user pressed Enter to get back to.

Filtering to two people at once has worked since people became a selector
term and was unreachable behind a two-screen round trip. It is a tray on
the filter bar now, where filters are.

The merge probability and the smallest group the clusterer will call a
person were constants tuned on one library. They are settings, edited
beside the Regroup button that applies them, with a read-only preview
that answers what they would do to *this* library.

And a hand brushing past a photograph no longer opens it: a finger has to
stay down long enough to have meant it, on a scale between the graze and
the hold that starts a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:53:13 +02:00
co-authored by Claude Opus 5
17 changed files with 2207 additions and 165 deletions
+17
View File
@@ -477,6 +477,23 @@ pub fn collections_for_image(
Ok(rows)
}
/// What kind of collection `id` is, or `None` if there is no such collection.
///
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
/// know whether member rows exist — the grid asks this to decide whether manual
/// position is a thing it can order by, and a smart collection has no
/// `collection_members` rows to carry one.
pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind>, CatalogError> {
let found = conn
.query_row(
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get::<_, i64>(0),
)
.optional()?;
Ok(found.map(CollectionKind::from_i64))
}
/// A collection and everything beneath it, including itself.
///
/// Used for cycle checks and for scoping the grid to a parent: selecting a
+155
View File
@@ -54,6 +54,7 @@ pub struct Settings {
pub develop: DevelopSettings,
pub import: ImportSettings,
pub library: LibrarySettings,
pub faces: FaceSettings,
}
// ---------------------------------------------------------------------------
@@ -109,6 +110,95 @@ impl Default for LibrarySettings {
}
}
// ---------------------------------------------------------------------------
// Faces
// ---------------------------------------------------------------------------
/// TRACES: FR-CULL-9 | FR-CULL-10
/// How hard the grouping pass tries to put two faces together, and how small a
/// group it will still call a person.
///
/// # Why these are settings at all
///
/// FR-CULL-10 is built on the clustering being wrong, and the two ways it is
/// wrong pull in opposite directions. Too loose and it welds siblings into one
/// person — the error the user cannot undo by hand. Too tight and a real person
/// arrives as nine fragments to be merged one at a time. The balance point is a
/// property of *the library*: how many people are in it, how closely related
/// they are, how far apart in years the photographs run. `dr_face`'s default was
/// measured on one 1,813-face reference library, and its own documentation says
/// so.
///
/// So the numbers the tuning harness prints are put on the screen instead of
/// staying in a doc comment. Regrouping is re-runnable by construction —
/// suggestions are the pass's own output and confirmations are never touched —
/// which is what makes a value the user can move safe to offer.
///
/// **Per device, not per library, like everything else in this file.** These
/// only decide what a *local* regrouping pass does; the people it produces are
/// catalog data and sync normally.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FaceSettings {
/// Probability above which two groups are judged to be one person.
///
/// A calibrated probability and never a bare similarity, which is
/// FR-CULL-9's standing rule for this subsystem — so the control the user
/// moves is in the same units as the confidence printed under every face.
pub merge_probability: f32,
/// The smallest group the pass will make a person out of.
///
/// A group of one is a stray, and naming every stray fills the People rail
/// with noise that has to be dismissed one entry at a time before the real
/// clusters are visible. Raising it is how a user with a crowded library
/// says "only show me people I have actually photographed more than once".
///
/// Groups that already carry a confirmation, a name, or an ignore are never
/// dropped by this, whatever their size: those are the user's judgements and
/// a display preference does not overrule them (FR-CULL-12).
pub min_group_size: u32,
}
impl FaceSettings {
/// What `dr_face` was tuned to, restated here because `dr-types` sits below
/// the face engine and must not depend on it.
///
/// `dr_ui::faces` holds the test that keeps the two numbers equal; a
/// default that drifted from the engine's would silently mean the settings
/// page's "default" marker pointed at a value the engine had abandoned.
pub const DEFAULT_MERGE_PROBABILITY: f32 = 0.80;
/// The range the settings control offers.
///
/// Not 0..1. Below about a half the pass stops building people and starts
/// melting them together — `dr_face::cluster`'s own measurements show the
/// group count *falling* while the grouped-face count rises, which is the
/// shape of over-merging — and above 0.95 almost nothing merges at all. A
/// slider whose ends are both useless spends most of its travel on answers
/// no one wants.
pub const PROBABILITY_RANGE: (f32, f32) = (0.50, 0.95);
/// The range the smallest-group control offers.
///
/// One means "show me every stray", which is a real thing to want while
/// hunting for a face the grouping missed. The top end is a judgement about
/// crowded libraries rather than a limit of the algorithm.
pub const GROUP_SIZE_RANGE: (u32, u32) = (1, 12);
}
impl Default for FaceSettings {
fn default() -> Self {
Self {
merge_probability: Self::DEFAULT_MERGE_PROBABILITY,
// Two, because a group of one is not evidence of anything. This is
// the number the clustering pass carried as a literal before it was
// a setting, so an existing library regroups identically until the
// user moves it.
min_group_size: 2,
}
}
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
@@ -884,6 +974,21 @@ impl Settings {
pub fn sanitise(&mut self) {
self.export.quality = self.export.quality.clamp(1, 100);
// Clamped to the range the slider offers rather than to 0..1. A
// probability of 0.02 is not a looser setting, it is a pass that welds
// the whole library into one person, and the file is hand-editable.
// NaN reaches here as a `f32` from JSON and survives every comparison,
// so it is answered explicitly instead of by `clamp`, which panics on
// it.
let (lo, hi) = FaceSettings::PROBABILITY_RANGE;
if !self.faces.merge_probability.is_finite() {
self.faces.merge_probability = FaceSettings::default().merge_probability;
}
self.faces.merge_probability = self.faces.merge_probability.clamp(lo, hi);
let (lo, hi) = FaceSettings::GROUP_SIZE_RANGE;
self.faces.min_group_size = self.faces.min_group_size.clamp(lo, hi);
// Snapped to an offered count rather than clamped to a range. The
// settings page lights the chip whose value matches, so a
// hand-edited 40 would leave every chip dark and the page unable to
@@ -1269,6 +1374,56 @@ mod tests {
assert!(s.export.destination.is_empty());
}
#[test]
fn sanitise_pulls_a_hand_edited_merge_probability_into_range() {
let mut s = Settings::default();
// The file is plain JSON in a config directory and a user is entitled
// to edit it. 0.02 is not a looser grouping, it is one person.
s.faces.merge_probability = 0.02;
s.sanitise();
assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.0);
s.faces.merge_probability = 4.0;
s.sanitise();
assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.1);
}
/// `f32::clamp` panics on a NaN bound and returns NaN for a NaN input, and
/// a NaN threshold silently groups nothing at all — every comparison
/// against it is false. JSON can carry one in.
#[test]
fn sanitise_answers_a_merge_probability_that_is_not_a_number() {
let mut s = Settings::default();
s.faces.merge_probability = f32::NAN;
s.sanitise();
assert_eq!(
s.faces.merge_probability,
FaceSettings::default().merge_probability
);
}
#[test]
fn sanitise_keeps_the_smallest_group_at_one_or_more() {
let mut s = Settings::default();
// Zero would be a pass that made a person out of nothing.
s.faces.min_group_size = 0;
s.sanitise();
assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.0);
s.faces.min_group_size = 9_000;
s.sanitise();
assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.1);
}
/// A settings file written before the dials existed is missing the whole
/// section, and has to load as the defaults rather than as a refusal.
#[test]
fn a_file_from_before_the_grouping_dials_still_loads() {
let older = r#"{"export":{"quality":90}}"#;
let s: Settings = serde_json::from_str(older).expect("older file should parse");
assert_eq!(s.faces, FaceSettings::default());
}
#[test]
fn sanitise_leaves_a_real_destination_alone() {
let mut s = Settings::default();
+34
View File
@@ -788,6 +788,40 @@ It is **not** a merge threshold and must not become one. Uniqueness is relative,
one named person would hand every stray face a 1. "Is this the same person at all" stays §8's
question, and coherence is the half of the product that carries it.
### 9.2 The two numbers the user is allowed to move · 2026-08-29
The merge probability and the smallest group the pass will call a person are `FaceSettings` in
`dr-types`, edited from the People screen and saved per device beside the cache budgets. They were
constants: `dr_face::DEFAULT_MERGE_PROBABILITY` and a bare `< 2` in `dr_ui::faces::recluster`.
**Why they had to become settings.** The default was tuned on one library — the table in
`dr_face::cluster`'s doc comment is 1,813 faces of one photographer's family — and the quantity it
optimises is a property of the population, not of the model. A library of one household at close
family resemblance and a library of two thousand strangers at a wedding want different answers, and
neither of them is the reference library. The doc comment already conceded the point ("this is a
*default*, not a constant of nature") and pointed at `face_index --tune` as the way to find a better
one; a photographer does not have a terminal.
**Why moving them is safe, and why that is the reason there is no confirmation on it.** A regroup
writes only the *suggested* half. Confirmations, names and ignores enter as anchors and come back
unchanged (FR-CULL-10), so the pass is re-runnable by construction and a dial the user can move is
just that property being used. The smallest-group rule is applied only to groups the system invented:
a group carrying a person — confirmed, named or set aside — survives it whatever its size, because a
display preference does not overrule a judgement (FR-CULL-12).
**Withdrawal, which the setting does not work without.** Raising the smallest group stops the pass
*creating* small groups; it does not by itself remove the ones a previous pass made, because those
still hold their suggestions, so they are not empty, so `prune_empty_unnamed` leaves them. The pass
therefore now releases every unanchored face it did not place — `faces::unassign` — before pruning.
Without that step the control appears to do nothing until the library is reindexed.
**The preview.** `dr_ui::faces::preview_grouping` runs the same population through
`dr_face::cluster` and reports groups, faces grouped and largest group without opening a
transaction. It is `face_index --tune`'s row for one setting, on the user's own library, on a worker
thread. The line leads with the **group count** because that is the number that says which side of
the right setting you are on: it climbs as fragments are gathered into people and falls as separate
people start being welded together, while the grouped-face count rises straight through both.
---
## 10. Catalog and jobs
+44 -44
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -88,7 +88,11 @@ fn main() {
// whole-library operation over the embeddings detection produced, and it is
// worth running *after* a sweep rather than during one (catalog.md §10.2).
if args.iter().any(|a| a == "--cluster") {
match dr_ui::faces::recluster(&catalog, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) {
// The engine's own defaults, not this device's settings file: a batch
// job run over a library on a server has no business inheriting the
// dials somebody moved on their laptop.
let grouping = dr_types::settings::FaceSettings::default();
match dr_ui::faces::recluster(&catalog, MODEL_ID, &grouping) {
Ok((suggested, created)) => {
println!("\nclustering: {suggested} suggestion(s), {created} new group(s)");
report_people(&catalog);
+303 -10
View File
@@ -43,6 +43,7 @@ use dr_types::{CollectionId, ImageId};
use rusqlite::OptionalExtension as _;
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::{AppWindow, CollectionRow};
/// The selection as it stood before a press, for [`cancel_press`].
@@ -156,6 +157,16 @@ pub struct CollectionsController {
/// develop view on the second ctrl-click. Slint does not report modifiers on
/// `clicked`, so the press records them and the click consults this.
modified_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-4
/// When the press that began the current gesture landed, and whether a
/// finger did it.
///
/// Read by the click that follows, to tell a tap from a graze — see
/// [`CollectionsController::press_was_a_graze`]. `None` between gestures,
/// which a click with no press before it is treated as: it cannot have been
/// deliberate if nothing pressed.
pressed_at: std::cell::Cell<Option<std::time::Instant>>,
touch_press: std::cell::Cell<bool>,
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether a tap in the grid selects rather than opens.
///
@@ -332,6 +343,38 @@ impl CollectionsController {
self.modified_press.get()
}
/// TRACES: FR-UI-4
/// Whether the contact that is ending was too brief to have meant anything.
///
/// A hand crossing a tablet on its way to the scroll it intended produces a
/// press and a release a few tens of milliseconds apart, in the same place —
/// indistinguishable, to a `TouchArea`, from a tap, and it was opening
/// whichever photograph happened to be under the knuckle. Travel is already
/// answered (the Flickable claims the pointer and the press is cancelled);
/// what was left is the contact that does not travel and does not last.
///
/// **Only a finger is held to this.** A mouse click is a discrete decision
/// made by a button and is routinely over in thirty milliseconds; applying
/// a dwell to it would make the desktop feel broken to fix a problem the
/// desktop does not have.
///
/// And it only withholds the *open*. The press has already selected the
/// cell under the finger, which is the right failure mode: a graze leaves
/// something visible and reversible on screen rather than silently doing
/// nothing, and rather than throwing the user into develop.
pub fn press_was_a_graze(&self) -> bool {
if !self.touch_press.get() {
return false;
}
match self.pressed_at.get() {
Some(at) => at.elapsed() < std::time::Duration::from_millis(TAP_MIN_MS),
// A click with no press recorded before it. Not a graze — there is
// nothing to say it was one — and refusing it would be the one way
// this rule could make a photograph unopenable.
None => false,
}
}
/// Selected image ids, in a stable order.
pub fn selected(&self) -> Vec<ImageId> {
self.selection.borrow().iter().copied().collect()
@@ -678,6 +721,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc<CollectionsController>, catalog
*ctl.row_smart.borrow_mut() = smart;
*ctl.row_has_children.borrow_mut() = has_kids;
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
// The rows are what `sync_reorderable` reads, so it has to be told they
// changed: a collection that gains a child, or one that is deleted out from
// under the scope, changes whether the grid can be reordered without the
// scope itself moving.
sync_reorderable(window);
}
/// TRACES: FR-NC-6a | FR-NC-6c
@@ -935,6 +984,29 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc<CollectionsController>, ids:
window.set_library_selected_count(selection.len() as i32);
}
/// TRACES: FR-CAT-7
/// Whether what the grid is showing has an order the user can change.
///
/// A single manual collection: not the whole library, not a saved filter whose
/// membership is a rule, and not a set — a set draws its descendants' images
/// too, and two children's `position` columns are unrelated integers that would
/// interleave arbitrarily.
///
/// Read off the tree rows the sidebar already draws rather than asked of the
/// catalog. `smart` and `has_children` are the only two facts it needs and both
/// are in the model already, so this cannot disagree with what the user is
/// looking at, and a scope change costs no extra query.
fn sync_reorderable(window: &AppWindow) {
let id = window.get_collection_selected();
let rows = window.get_collection_rows();
let manual = id > 0
&& (0..rows.row_count())
.filter_map(|i| rows.row_data(i))
.find(|r| r.id == id)
.is_some_and(|r| !r.smart && !r.has_children);
window.set_library_reorderable(manual);
}
/// Refresh the per-cell "in this many collections" badges.
///
/// One query for the whole window rather than one per cell: 120 cells is 120
@@ -1002,6 +1074,22 @@ const SPRING_DELAY_MS: u64 = 500;
/// the hand lets go first, having concluded nothing was going to happen.
pub(crate) const HOLD_DELAY_MS: u64 = 450;
/// TRACES: FR-UI-4
/// How long a finger must stay down before letting go counts as opening a
/// photograph.
///
/// **The floor under a tap, where `HOLD_DELAY_MS` is the ceiling.** Between the
/// two is a tap; below is a graze that only selects; above is a hold that
/// starts a selection. The three have to be one scale or the gesture set stops
/// being learnable.
///
/// 120 ms is a tenth of the hold and about twice a brush. It costs nothing in
/// felt latency because it does not *delay* anything — the open still happens
/// on release, and this only decides whether that release counted — so the
/// error it can make is one-sided: an unusually quick deliberate tap selects
/// instead of opening, and the photograph is one further tap away.
pub(crate) const TAP_MIN_MS: u64 = 120;
/// TRACES: FR-UI-2 | FR-UI-4
/// Start the timer that turns a held cell into a selection.
///
@@ -1471,13 +1559,17 @@ pub fn wire<S, R, P, C>(
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
// Consulted by the click that follows: a modified press is building
// a selection and must not also navigate to develop.
ctl.modified_press.set(ctrl_held || shift_held);
// And so is this: how long the contact lasts is what separates a
// tap from a graze, and only the press knows when it started.
ctl.pressed_at.set(Some(std::time::Instant::now()));
ctl.touch_press.set(touch);
// Where the window starts, so the press is recorded as the ordinal
// it is rather than as a row that stops meaning this photograph on
@@ -1574,6 +1666,142 @@ pub fn wire<S, R, P, C>(
});
}
// TRACES: FR-CAT-7
// Drag a photograph, or a whole selection of them, to a new place in the
// collection being shown.
//
// `collection_members.position` and `Sort::CollectionPosition` have been in
// the catalog since collections were, and until now nothing above it ever
// wrote or read them — the grid ordered by capture time whatever it was
// scoped to. `library::grid_order_for` reads them; this writes them.
//
// The membership is rewritten whole rather than patched. `set_order` sets
// the positions it is given and leaves the rest, so a partial write would
// interleave the moved run with rows whose positions nobody touched — and
// it is read unfiltered for the same reason: the user reorders what they
// can see, and what the filter is hiding keeps its place relative to it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_reorder_to(move |row, after| {
let Some(w) = weak.upgrade() else { return };
// Both guards belong here rather than only in `library.slint`: the
// scope can change between the drag starting and the drop landing.
if !w.get_library_reorderable() {
return;
}
let Some(scope) = *ctl.scope.borrow() else {
return;
};
let moving = ctl.selected();
if moving.is_empty() {
return;
}
let ids = visible();
let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else {
return;
};
// Dropped on one of its own. There is no gap between a run and
// itself to land in, and rewriting the whole membership to say so
// would be a revision bump for no change.
if moving.contains(&target) {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
let current = match library::read_member_order(cat, scope) {
Ok(current) => current,
Err(e) => {
w.set_collection_error(format!("reading the collection's order: {e}").into());
return;
}
};
let wanted = library::reordered(&current, &moving, target, after);
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
moving.len(),
if moving.len() == 1 { "" } else { "s" }
)
.into(),
);
drop(borrow);
// The selection survives. It is what was just moved, and
// dropping it would make a second nudge — which is how a
// drag-to-reorder is usually corrected — start over.
reload();
sync_selection(&w, &ctl, &visible());
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("reordering: {e}").into());
}
}
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// Everything the grid is showing.
//
// Asked of the catalog through `span`, not read off the loaded window, for
// the reason spelled out in `apply_press`: the window is a hundred cells
// over a library of thousands, and a "select all" that quietly meant
// "select the hundred that happen to be loaded" is a lie the user cannot
// see until the export runs. `library_total` is the count the same scope
// and filter produced, so the run is the whole of what the grid claims.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted the rest kept would not have reached for
// this.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The loaded window is the fallback, not the answer — the same
// trade the shift-click path makes. An empty span means no library
// or a failed query, and selecting what is on screen beats
// selecting nothing.
let all = match ctl.span(0, total - 1) {
run if run.is_empty() => ids.clone(),
run => run,
};
{
let mut selection = ctl.selection.borrow_mut();
selection.clear();
selection.extend(all);
}
// The first frame becomes the anchor, so a "Select to…" straight
// afterwards describes a range from the top rather than from
// wherever the last individual tap left it — which, after taking
// everything, is not a place the user is still thinking about.
*ctl.anchor.borrow_mut() = Some(0);
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-CAT-5
// A collection holding exactly what is selected.
//
@@ -1586,13 +1814,20 @@ pub fn wire<S, R, P, C>(
// the tree's "+". A selection can be gathered from anywhere, including
// across collections, so filing it under whichever one happens to be open
// would put it somewhere its contents did not come from.
//
// The name arrives already chosen. It used to be a placeholder, with the
// sidebar's rename field opened straight afterwards to correct it — which
// on a tablet meant opening a field inside a panel that is instantiated but
// not drawn, so it took the on-screen keyboard and could never give it
// back. `library.slint`'s naming sheet asks first, and nothing reaches the
// catalog until it is answered.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_collection_from_selection(move || {
window.on_library_collection_from_selection(move |name| {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
@@ -1605,12 +1840,24 @@ pub fn wire<S, R, P, C>(
return;
};
let name = unique_name(cat.connection(), None);
// The sheet refuses an empty name in two places, so this is
// belt-and-braces rather than a path the UI can reach — but a
// collection with no name at all is unfindable in the tree, and
// falling back to the placeholder is recoverable where an empty
// row is not.
let name = match name.trim() {
"" => unique_name(cat.connection(), None),
typed => typed.to_string(),
};
// The id is not carried out. It used to be, to open the rename
// field on the row it names; now the name is already right, and
// `refresh_tree` redraws the tree from the catalog either way.
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images).map(|n| (id, n)));
.and_then(|id| coll::add_images(cat.connection(), id, &images));
match made {
Ok((id, added)) => {
Ok(added) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
@@ -1628,11 +1875,10 @@ pub fn wire<S, R, P, C>(
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// Into the name field, for the same reason `collection_new`
// does it: "New collection" is a placeholder nobody wants
// to keep, and making them find the rename afterwards is
// asking them to finish a job we started.
w.set_collection_renaming(id.0 as i32);
// No rename opened here. The name was chosen before the
// collection existed, so there is nothing left to correct —
// and opening the sidebar's field is what stranded the
// keyboard on a tablet.
reload();
}
Err(e) => {
@@ -2164,6 +2410,7 @@ pub fn wire<S, R, P, C>(
};
w.set_collection_selected(id);
w.set_collection_scope_label(label.into());
sync_reorderable(&w);
reload();
});
}
@@ -2554,6 +2801,52 @@ mod tests {
Vec::new()
}
/// TRACES: FR-UI-4
/// A hand brushing the tablet on its way to a scroll must not open a
/// photograph. It still *selects* one — the press did that, and something
/// visible and reversible is the right thing to be left with.
#[test]
fn a_graze_does_not_open_a_photograph() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(true);
ctl.pressed_at.set(Some(std::time::Instant::now()));
assert!(
ctl.press_was_a_graze(),
"a release in the same instant as the press is not a tap"
);
}
/// And a finger that stayed down is a tap, not a graze.
#[test]
fn a_deliberate_tap_opens_a_photograph() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(true);
ctl.pressed_at.set(Some(
std::time::Instant::now() - std::time::Duration::from_millis(TAP_MIN_MS + 10),
));
assert!(!ctl.press_was_a_graze());
}
/// The desktop is not held to the dwell. A mouse button is a discrete
/// decision and is routinely down for thirty milliseconds.
#[test]
fn a_mouse_click_is_never_a_graze() {
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
ctl.touch_press.set(false);
ctl.pressed_at.set(Some(std::time::Instant::now()));
assert!(!ctl.press_was_a_graze());
}
/// The floor has to sit under the ceiling, or there is no tap between them:
/// every press would be either a graze or a hold.
#[test]
fn a_tap_has_room_between_a_graze_and_a_hold() {
// A `const` block, so this is a compile error rather than a test
// failure: the two numbers are constants, and a gesture set with no
// room for a tap in it should not get as far as being run.
const { assert!(TAP_MIN_MS < HOLD_DELAY_MS) };
}
#[test]
fn dragging_a_collection_onto_another_moves_it() {
// The gesture the tree rearrangement exists for: no images carried, a
+347 -75
View File
@@ -30,6 +30,7 @@ use dr_catalog::faces::{self, DetectedFace};
use dr_catalog::Catalog;
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
use dr_thumbs::{ThumbSize, ThumbStore};
use dr_types::settings::FaceSettings;
use dr_types::ImageId;
/// The tier faces are found on. See the module note.
@@ -476,6 +477,148 @@ pub fn spawn_store_face_sweep(
rx
}
/// Everything a grouping pass reads before it decides anything.
///
/// Split out because two callers need exactly this and must agree on it: the
/// pass that writes, and the preview that reports what the pass *would* do. A
/// preview built from a second, subtly different reading — anchors omitted,
/// say — would answer a question about a library nobody has.
struct Population {
cal: Calibration,
candidates: Vec<dr_face::Candidate>,
/// Catalog ids, parallel to `candidates`.
ids: Vec<faces::FaceId>,
/// Faces the user has ruled on, and who they are. See [`Population::read`].
anchors: std::collections::HashMap<faces::FaceId, faces::PersonId>,
}
impl Population {
/// Takes the catalog and not its connection: `rusqlite` is `dr-catalog`'s
/// dependency and not this crate's, and reaching for the connection type by
/// name here would drag it across a layer that has kept clear of it.
fn read(catalog: &Catalog, model_id: &str) -> Result<Self, dr_catalog::CatalogError> {
let conn = catalog.connection();
// No valid calibration is not a reason to refuse to cluster — it is a
// reason not to *display* a confidence (FR-CULL-9). `Calibration::
// default` is the reference implementation's fitted curve with `valid`
// false, which is a documented operating point rather than an invented
// one.
let cal = faces::calibration(conn, model_id)?
.map(|(c, _)| c)
.unwrap_or_else(Calibration::default);
// Which faces the user has already ruled on, so they enter as anchors.
//
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did
// the most damage, because it is silent. Naming a cluster does not
// confirm its faces — they stay suggestions — so the next Regroup cut
// them loose, regrouped them into a brand new person, and left the named
// one holding nothing. `prune_empty_unnamed` will not remove it, because
// it has a name. Name the new group the same thing and it happens again.
// That is how one library came to hold sixteen people called Catherine,
// fourteen of them empty, with her faces split across the two that were
// not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed
// face that matches a named person now merges *into* them rather than
// arriving as a stranger.
let mut anchors = std::collections::HashMap::new();
for p in faces::people(conn)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
anchors.insert(f.id, p.id);
}
}
let stored = faces::embeddings(conn, model_id)?;
let model = ModelId::new(model_id.to_string());
let mut candidates = Vec::with_capacity(stored.len());
let mut ids = Vec::with_capacity(stored.len());
for (face_id, image_id, blob, crop_px) in stored {
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
log::warn!("face {face_id:?} has a malformed embedding, skipped");
continue;
};
candidates.push(dr_face::Candidate {
face: face_id.0,
image: image_id.0,
embedding: emb.v.to_vec(),
crop_px,
confirmed_person: anchors.get(&face_id).map(|p| p.0),
});
ids.push(face_id);
}
Ok(Self {
cal,
candidates,
ids,
anchors,
})
}
}
/// What a grouping pass would produce, without producing it.
///
/// The numbers `dr_face::cluster`'s own tuning table is built from, for one
/// setting rather than ten — because the question a photographer is actually
/// asking of the dials is "what does *my* library look like at this value", and
/// the doc-comment table answers it for a library that is not theirs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GroupingPreview {
/// Faces that went in.
pub faces: usize,
/// Groups that would survive the smallest-group rule.
pub groups: usize,
/// Faces those groups would hold.
pub grouped: usize,
/// The biggest group. The tell for over-merging: it is the number that runs
/// away when the confidence is set too low.
pub largest: usize,
}
/// Report what a grouping pass would do, writing nothing.
///
/// Read-only by construction — it never opens a transaction — which is what
/// makes it safe to run repeatedly while the user moves a slider. Comparing two
/// settings by *applying* both would leave the second one's answer polluted by
/// the first one's suggestions.
pub fn preview_grouping(
catalog: &Catalog,
model_id: &str,
grouping: &FaceSettings,
) -> Result<GroupingPreview, dr_catalog::CatalogError> {
let min_group = grouping.min_group_size.max(1) as usize;
let pop = Population::read(catalog, model_id)?;
if pop.candidates.is_empty() {
return Ok(GroupingPreview::default());
}
let clusters = dr_face::cluster(&pop.candidates, &pop.cal, grouping.merge_probability);
// The same rule the writing pass applies, so the preview and the result
// cannot disagree about what counts as a person.
let kept: Vec<_> = clusters
.iter()
.filter(|c| c.members.len() >= min_group || c.person.is_some())
.collect();
Ok(GroupingPreview {
faces: pop.candidates.len(),
groups: kept.len(),
grouped: kept.iter().map(|c| c.members.len()).sum(),
largest: kept.iter().map(|c| c.members.len()).max().unwrap_or(0),
})
}
/// Group the library's faces into people, writing suggestions.
///
/// Confirmations are never touched: they enter the clusterer as anchors and
@@ -487,72 +630,22 @@ pub fn spawn_store_face_sweep(
pub fn recluster(
catalog: &Catalog,
model_id: &str,
min_probability: f32,
grouping: &FaceSettings,
) -> Result<(usize, usize), dr_catalog::CatalogError> {
let min_probability = grouping.merge_probability;
let min_group = grouping.min_group_size.max(1) as usize;
let conn = catalog.connection();
// No valid calibration is not a reason to refuse to cluster — it is a
// reason not to *display* a confidence (FR-CULL-9). `Calibration::default`
// is the reference implementation's fitted curve with `valid` false, which
// is a documented operating point rather than an invented one.
let cal = faces::calibration(conn, model_id)?
.map(|(c, _)| c)
.unwrap_or_else(Calibration::default);
let stored = faces::embeddings(conn, model_id)?;
if stored.is_empty() {
let Population {
cal,
candidates,
ids,
anchors: confirmed,
} = Population::read(catalog, model_id)?;
if candidates.is_empty() {
return Ok((0, 0));
}
// Which faces the user has already ruled on, so they enter as anchors.
//
// Anything the user has ruled on anchors, and there are three ways of
// ruling — only the first of which is obvious.
//
// A **confirmation** is the plain case. **Setting a group aside** is one
// too, and the faces it covers are only ever suggestions, so anchoring
// confirmations alone let every ignored group scatter into fresh unnamed
// groups that were not ignored, and the strangers came straight back.
//
// And so is **giving a group a name**. That was the omission that did the
// most damage, because it is silent. Naming a cluster does not confirm its
// faces — they stay suggestions — so the next Regroup cut them loose,
// regrouped them into a brand new person, and left the named one holding
// nothing. `prune_empty_unnamed` will not remove it, because it has a name.
// Name the new group the same thing and it happens again. That is how one
// library came to hold sixteen people called Catherine, fourteen of them
// empty, with her faces split across the two that were not.
//
// A name is a judgement about *this group* (FR-CULL-12), exactly as an
// ignore is. Anchoring them all also does one better: a newly indexed face
// that matches a named person now merges *into* them rather than arriving
// as a stranger.
let mut confirmed = std::collections::HashMap::new();
for p in faces::people(conn)? {
let ruled_on = p.ignored || !p.name.trim().is_empty();
for f in faces::for_person(conn, p.id, ruled_on)? {
confirmed.insert(f.id, p.id);
}
}
let model = ModelId::new(model_id.to_string());
let mut candidates = Vec::with_capacity(stored.len());
let mut ids = Vec::with_capacity(stored.len());
for (face_id, image_id, blob, crop_px) in stored {
let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else {
log::warn!("face {face_id:?} has a malformed embedding, skipped");
continue;
};
candidates.push(dr_face::Candidate {
face: face_id.0,
image: image_id.0,
embedding: emb.v.to_vec(),
crop_px,
confirmed_person: confirmed.get(&face_id).map(|p| p.0),
});
ids.push(face_id);
}
let dr_face::Grouping {
clusters,
confidence,
@@ -560,10 +653,20 @@ pub fn recluster(
let mut suggested = 0usize;
let mut created = 0usize;
// Every face this pass actually placed. What is *not* in here at the end is
// a face the previous pass had an opinion about and this one does not, and
// it has to be let go — see below.
let mut placed = std::collections::HashSet::with_capacity(ids.len());
for c in &clusters {
// A group of one is not a person. Naming every stray face would fill
// the People view with noise the user then has to dismiss.
if c.members.len() < 2 && c.person.is_none() {
// A group of one is not a person, and the user says how much bigger
// than one it has to be (`FaceSettings::min_group_size`). Naming every
// stray face would fill the People view with noise they then have to
// dismiss one entry at a time.
//
// Only ever applied to a group the system invented. A group with a
// `person` is one the user has already confirmed, named or set aside,
// and a display preference does not overrule a judgement (FR-CULL-12).
if c.members.len() < min_group && c.person.is_none() {
continue;
}
@@ -579,6 +682,7 @@ pub fn recluster(
for &m in &c.members {
let face = ids[m];
placed.insert(face);
if confirmed.contains_key(&face) {
continue;
}
@@ -594,6 +698,31 @@ pub fn recluster(
}
}
// Faces the previous pass placed and this one did not.
//
// Without this the parameters above are only half connected to the screen.
// Raise the smallest group to three and the pass stops *creating* groups of
// two — but last pass's group of two still holds its two suggestions, so it
// is not empty, so the prune below leaves it, and the rail does not change.
// The setting would appear to do nothing until the library was reindexed.
//
// Suggestions are this pass's own output (FR-CULL-10), so withdrawing one it
// no longer stands behind is exactly what it is entitled to do. Anchors are
// skipped by construction: a confirmed, named or ignored face is in
// `confirmed`, enters as an anchor, and comes back out inside a group that
// is never dropped.
let mut released = 0usize;
for face in &ids {
if placed.contains(face) || confirmed.contains_key(face) {
continue;
}
faces::unassign(conn, *face)?;
released += 1;
}
if released > 0 {
log::info!("reclustering released {released} face(s) it no longer groups");
}
// Groups the *previous* pass created that this one left empty. Without
// this, every press of Regroup adds a rail entry per group it no longer
// believes in, and the screen fills with "Unnamed (0 faces)" — which is
@@ -612,6 +741,43 @@ pub fn recluster(
Ok((suggested, created))
}
/// The answer from a preview pass.
#[derive(Debug, Clone, PartialEq)]
pub enum PreviewMessage {
Ready(GroupingPreview),
Failed(String),
}
/// Report what a grouping pass would do, **on a worker thread**.
///
/// Same reasoning as [`spawn_recluster`], and the same cost: a preview is a
/// clustering pass that throws its answer away, so it is exactly as unbounded
/// as the pass it is previewing and exactly as unwelcome on the UI thread.
///
/// Cancellation is dropping the receiver — so moving the slider again while one
/// is in flight abandons it, which is the behaviour a dial with a preview
/// button needs.
pub fn spawn_grouping_preview(
catalog_path: PathBuf,
model_id: String,
grouping: FaceSettings,
) -> Receiver<PreviewMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let msg = match Catalog::open(&catalog_path) {
Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) {
Ok(p) => PreviewMessage::Ready(p),
Err(e) => PreviewMessage::Failed(e.to_string()),
},
Err(e) => PreviewMessage::Failed(format!("cannot open catalog: {e}")),
};
let _ = tx.send(msg);
});
rx
}
/// Progress from a regrouping pass.
#[derive(Debug, Clone, PartialEq)]
pub enum ReclusterMessage {
@@ -643,7 +809,7 @@ pub enum ReclusterMessage {
pub fn spawn_recluster(
catalog_path: PathBuf,
model_id: String,
min_probability: f32,
grouping: FaceSettings,
) -> Receiver<ReclusterMessage> {
let (tx, rx) = std::sync::mpsc::channel();
@@ -667,7 +833,7 @@ pub fn spawn_recluster(
return;
}
let msg = match recluster(&catalog, &model_id, min_probability) {
let msg = match recluster(&catalog, &model_id, &grouping) {
Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created },
Err(e) => ReclusterMessage::Failed(e.to_string()),
};
@@ -1125,7 +1291,7 @@ mod tests {
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1, "the two faces should have grouped");
let stranger = people[0].id;
@@ -1133,7 +1299,7 @@ mod tests {
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
@@ -1157,13 +1323,13 @@ mod tests {
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let stranger = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
// The same person turns up in a third photograph.
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a new face made a second group: {after:?}");
@@ -1177,13 +1343,13 @@ mod tests {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let id = faces::people(catalog.connection()).unwrap()[0].id;
faces::set_ignored(catalog.connection(), id, true).unwrap();
faces::set_ignored(catalog.connection(), id, false).unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1);
assert!(!after[0].ignored);
@@ -1200,7 +1366,7 @@ mod tests {
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let people = faces::people(catalog.connection()).unwrap();
assert_eq!(people.len(), 1);
let her = people[0].id;
@@ -1210,7 +1376,7 @@ mod tests {
// types a name and moves on has done.
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
@@ -1233,15 +1399,121 @@ mod tests {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
put_face(&catalog, 3, 0, 0.98);
recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap();
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
assert_eq!(after[0].suggested_faces, 3);
}
/// `dr-types` sits below the face engine and cannot name its constant, so
/// it restates the number. This is the thing that stops the two drifting:
/// a settings page marking 0.80 as "default" while the engine had moved to
/// 0.75 would put the reset dot on a value nothing else agreed with.
#[test]
fn the_settings_default_is_the_engines_own_tuned_value() {
assert_eq!(
FaceSettings::default().merge_probability,
dr_face::DEFAULT_MERGE_PROBABILITY
);
}
/// The smallest-group setting has to change what is on the rail, not just
/// what the *next* pass would build. Before the release step in
/// [`recluster`], raising it left the previous pass's small groups sitting
/// there full of suggestions — not empty, so not pruned — and the control
/// looked broken.
#[test]
fn raising_the_smallest_group_takes_the_small_groups_off_the_rail() {
let catalog = catalog_with(5);
// One pair, and one trio: two groups at the default of two.
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
put_face(&catalog, 3, 1, 1.0);
put_face(&catalog, 4, 1, 0.99);
put_face(&catalog, 5, 1, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert_eq!(
faces::people(catalog.connection()).unwrap().len(),
2,
"the two groups should have formed at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 3,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(
after.len(),
1,
"the pair survived a smallest-group of three: {after:?}"
);
assert_eq!(after[0].suggested_faces, 3, "the trio lost members");
}
/// And the setting does not overrule the user. A group they named is theirs
/// (FR-CULL-12), however few faces it holds.
#[test]
fn a_named_group_survives_a_smallest_group_it_is_under() {
let catalog = catalog_with(3);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 0, 0.99);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let her = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 6,
..FaceSettings::default()
},
)
.unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "Catherine was dropped: {after:?}");
assert_eq!(after[0].name, "Catherine");
assert_eq!(after[0].suggested_faces, 2);
}
/// Down at one, every stray becomes a group of its own — which is what a
/// user hunting for a face the grouping missed has asked for.
#[test]
fn a_smallest_group_of_one_shows_the_strays() {
let catalog = catalog_with(2);
put_face(&catalog, 1, 0, 1.0);
put_face(&catalog, 2, 1, 1.0);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
assert!(
faces::people(catalog.connection()).unwrap().is_empty(),
"two unrelated faces made a person at the default"
);
recluster(
&catalog,
TEST_MODEL,
&FaceSettings {
min_group_size: 1,
..FaceSettings::default()
},
)
.unwrap();
assert_eq!(faces::people(catalog.connection()).unwrap().len(), 2);
}
}
+48
View File
@@ -117,6 +117,29 @@ impl FaceCell {
}
}
/// Put a grouping preview into words.
///
/// **The group count leads, not the grouped-face count.** They move in opposite
/// directions on either side of the right setting, and only one of them says
/// which side you are on: loosening gathers fragments into people, so the group
/// count climbs — until it starts welding separate people together, at which
/// point it *falls* while the grouped faces keep rising. `dr_face::cluster`
/// records that measurement at length. A line that led with "1,340 of 1,813
/// faces grouped" would make the over-merged setting look like the best one.
///
/// The largest group is here for the same reason: it is where over-merging
/// shows up first and most legibly, because a user who knows their own library
/// knows whether anyone in it has been photographed six hundred times.
pub fn preview_label(p: &crate::faces::GroupingPreview) -> String {
if p.faces == 0 {
return "No faces indexed yet, so there is nothing to group.".into();
}
format!(
"{} group(s), holding {} of {} faces. Largest: {}.",
p.groups, p.grouped, p.faces, p.largest
)
}
/// Everything the Identity screen draws.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct IdentityView {
@@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result<u64, dr_catalog::CatalogError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::faces::GroupingPreview;
use dr_catalog::faces::DetectedFace;
#[test]
fn a_preview_leads_with_the_group_count() {
let line = preview_label(&GroupingPreview {
faces: 1813,
groups: 328,
grouped: 1341,
largest: 69,
});
// The group count is the number that says which side of the right
// setting you are on, so it is the number the sentence starts with.
assert!(line.starts_with("328 group"), "{line}");
assert!(line.contains("1341 of 1813"), "{line}");
assert!(line.contains("69"), "{line}");
}
/// The ordinary state of a library nobody has run the indexer over — and
/// "0 group(s), holding 0 of 0 faces" would read as a failure of the dials
/// rather than as an absence of input.
#[test]
fn a_preview_of_nothing_says_there_is_nothing() {
let line = preview_label(&GroupingPreview::default());
assert!(line.contains("No faces indexed"), "{line}");
}
fn catalog() -> Catalog {
let dir = std::env::temp_dir().join(format!(
"dr-identity-{}-{:?}",
+137 -1
View File
@@ -81,6 +81,12 @@ pub struct IdentityController {
/// 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.
@@ -378,12 +384,33 @@ pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathB
/// The detector and embedder files, when both are present.
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
/// Put the grouping dials on the screen from the settings record.
///
/// Read back out of the controller rather than echoed from the callback's
/// argument, because `Settings::sanitise` may have moved the number: a slider
/// showing 40% while the file held the clamped 50% would be a control that
/// silently disagreed with what the next Regroup was going to do.
fn push_grouping(window: &AppWindow, settings: &crate::settings_ui::SettingsController) {
let s = settings.snapshot();
window.set_identity_merge_probability(s.faces.merge_probability * 100.0);
window.set_identity_min_group_size(s.faces.min_group_size as i32);
}
/// Attach every Identity callback.
///
/// Eight arguments because the screen has eight distinct dependencies and no
/// two of them belong together: three ways of reaching the library, two
/// controllers, the window, the activity log and the settings record. Bundling
/// them into a parameter struct would name a thing that does not exist — the
/// same reason every other `wire` in this file's neighbourhood carries the
/// allow.
#[allow(clippy::too_many_arguments)]
pub fn wire<S, M, P>(
window: &AppWindow,
ctl: Rc<IdentityController>,
catalog: Rc<RefCell<Option<Catalog>>>,
activity: Rc<crate::activity::ActivityLog>,
settings: Rc<crate::settings_ui::SettingsController>,
store: S,
models: M,
paths: P,
@@ -396,6 +423,36 @@ pub fn wire<S, M, P>(
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
// The dials start where the settings file left them, once, rather than on
// every open: the screen writes them back through the two callbacks below,
// and re-pushing them mid-drag would fight the slider's own live value.
push_grouping(window, &settings);
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_merge_probability_changed(move |percent| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.merge_probability = percent / 100.0);
push_grouping(&w, &settings);
// The answer on screen was about the old value. Left there it would
// be read as a description of the new one, which is worse than
// having no preview at all.
w.set_identity_grouping_preview(Default::default());
});
}
{
let weak = window.as_weak();
let settings = settings.clone();
window.on_identity_min_group_size_changed(move |n| {
let Some(w) = weak.upgrade() else { return };
settings.edit(|s| s.faces.min_group_size = n.max(0) as u32);
push_grouping(&w, &settings);
w.set_identity_grouping_preview(Default::default());
});
}
// Re-read everything and redraw. Every mutating callback ends in this
// rather than patching the model in place: the operations here have
// second-order effects — a merge empties a person, a split creates one,
@@ -685,12 +742,76 @@ pub fn wire<S, M, P>(
});
}
{
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.to_string(),
settings.snapshot().faces,
));
let timer = slint::Timer::default();
let weak_tick = w.as_weak();
let ctl_tick = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
Duration::from_millis(100),
move || {
let Some(w) = weak_tick.upgrade() else { return };
let mut done = false;
{
let borrow = ctl_tick.preview.borrow();
let Some(rx) = borrow.as_ref() else { return };
while let Ok(msg) = rx.try_recv() {
match msg {
crate::faces::PreviewMessage::Ready(p) => {
w.set_identity_grouping_preview(
identity::preview_label(&p).into(),
);
done = true;
}
crate::faces::PreviewMessage::Failed(e) => {
log::warn!("identity: preview: {e}");
w.set_identity_grouping_preview(
format!("could not work it out: {e}").into(),
);
done = true;
}
}
}
}
if done {
*ctl_tick.preview.borrow_mut() = None;
w.set_identity_previewing(false);
}
},
);
park_preview_timer(timer);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let store = store.clone();
let paths = paths.clone();
let settings_for_regroup = settings.clone();
window.on_identity_recluster(move || {
let Some(w) = weak.upgrade() else { return };
// One at a time. Two passes over the same faces would each create
@@ -708,7 +829,7 @@ pub fn wire<S, M, P>(
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
catalog_path,
MODEL_ID.to_string(),
dr_face::DEFAULT_MERGE_PROBABILITY,
settings_for_regroup.snapshot().faces,
));
// Polled from the UI thread, like the indexing sweep: the worker
@@ -984,6 +1105,21 @@ fn park_timer(timer: slint::Timer) {
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));
}
#[cfg(test)]
mod tests {
use super::*;
+14 -10
View File
@@ -1013,6 +1013,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// faces are ticked for a split.
let identity = std::rc::Rc::new(identity_ui::IdentityController::new());
// Settings: cache ceilings, export defaults and the face grouping dials, in
// their own config file.
//
// Wired independently of every view below. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted this far up because three separate things need the same record:
// the export action, the importer, and the People screen's grouping dials.
// A controller scoped to any one wiring block would be gone by the time the
// others were built, and two controllers each holding their own copy would
// each save over the other.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-PLAT-AND-5
// The thumbnail tier. Registered here, beside the thing it frees, so that
// a controller which grows another cache is one line from offering it up.
@@ -1116,6 +1129,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
identity.clone(),
library.catalog(),
activity.clone(),
settings.clone(),
move || {
let conn = lib_store.session()?;
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
@@ -1188,16 +1202,6 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
}
}
// Settings: cache ceilings and export defaults, in their own config file.
//
// Wired independently of every view above. It reads no library and holds no
// session, so it has nothing to be sequenced against — which is the reason
// it is a page reachable from anywhere rather than a panel inside one view.
// Hoisted out of the block below: the export action needs the same record
// the settings page edits, and a controller scoped to the wiring block
// would be gone by the time that callback is built.
let settings = settings_ui::SettingsController::new();
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
// Import: a card into the library, and on to the server.
//
+334 -6
View File
@@ -3910,9 +3910,10 @@ pub fn read_cells(
/// look broken. The id list comes from
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
///
/// Ordering matches the unscoped grid (capture time, then name) rather than
/// manual position: position is only meaningful inside one collection and this
/// query also serves sets, where two children's positions are unrelated.
/// Ordering comes from [`grid_order_for`]: manual position for a single manual
/// collection, capture time for a set — because position is only meaningful
/// inside one collection, and this query also serves sets, where two children's
/// positions are unrelated integers.
pub fn read_cells_scoped(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
@@ -3930,6 +3931,7 @@ pub fn read_cells_scoped(
.collect::<Vec<_>>()
.join(",");
let rated = filter.sql();
let (order, order_params) = grid_order_for(catalog, Some(scope));
let folded = uncollapsed("i");
let sql = format!(
"SELECT {CELL_COLUMNS}
@@ -3937,14 +3939,17 @@ pub fn read_cells_scoped(
WHERE {VISIBLE}{rated}{folded}
AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))
{GRID_ORDER}
{order}
LIMIT ? OFFSET ?"
);
// Bound in the order the `?`s appear: the scope's ids in the WHERE, then
// whatever the ORDER BY needs, then the window.
let mut params: Vec<rusqlite::types::Value> = ids
.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect();
params.extend(order_params);
params.push(rusqlite::types::Value::Integer(limit as i64));
params.push(rusqlite::types::Value::Integer(offset as i64));
@@ -4070,14 +4075,23 @@ pub fn read_ids_span(
Vec::new(),
)
} else {
let (clause, params) = scope_clause(catalog, scope)?;
let (clause, mut params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
// The same ordering the cells were drawn with, from the same place.
// A range is a pair of ordinals, and an ordinal read through a
// different ORDER BY names a different photograph.
let (order, order_params) = grid_order_for(catalog, scope);
params.extend(order_params);
// And the same folding, for the same reason one step further on: a
// collapsed burst is one cell in the grid, so an ordinal counted over
// a list that still held every frame of it would name a photograph
// several places away from the one the user pointed at.
let folded = uncollapsed("i");
(
format!(
"SELECT i.id FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}
{GRID_ORDER}
{order}
LIMIT ? OFFSET ?"
),
params,
@@ -4225,6 +4239,146 @@ pub fn total_images_scoped(
Ok(n as usize)
}
/// TRACES: FR-CAT-7
/// Every member of `scope`, in the order its positions put them.
///
/// The *whole* membership, not the window and not the filtered view. A reorder
/// rewrites positions, and [`dr_catalog::collections::set_order`] only touches
/// the rows it is given — so writing back a filtered subset would leave the
/// images the filter is hiding at their old positions, interleaved with the new
/// ones arbitrarily. The user reorders what they can see; the rows they cannot
/// keep their place relative to it.
pub fn read_member_order(
catalog: &Catalog,
scope: dr_types::CollectionId,
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(
"SELECT image_id FROM collection_members
WHERE collection_id = ?1
ORDER BY position ASC, image_id ASC",
)?;
let ids = stmt
.query_map([scope.0 as i64], |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
/// TRACES: FR-CAT-7
/// `current` with `moving` lifted out and set down beside `target`.
///
/// `target` names a *photograph*, not an index, and that is the point: the grid
/// may be filtered, so the cell the user dropped on sits at one position in
/// what they can see and another in the membership being rewritten. An id
/// survives both. `after` puts the run on the far side of it, which is the only
/// way to name the last place in a collection — there is no cell beyond the
/// last one to drop in front of.
///
/// The run keeps the order `current` has it in rather than the order the
/// selection was built in: the user is looking at the grid, and a selection
/// gathered by tapping the last frame first should not reverse itself on being
/// moved.
///
/// A `target` that is itself being moved leaves the run at the end. There is no
/// gap between a run and itself to land in, so the caller refuses that drop
/// before it gets here; this is what the function does rather than panicking if
/// one ever arrives.
///
/// Pure, so the awkward half of a drag can be tested without a window.
pub fn reordered(
current: &[dr_types::ImageId],
moving: &[dr_types::ImageId],
target: dr_types::ImageId,
after: bool,
) -> Vec<dr_types::ImageId> {
let lifting: std::collections::BTreeSet<_> = moving.iter().copied().collect();
let rest: Vec<_> = current
.iter()
.copied()
.filter(|id| !lifting.contains(id))
.collect();
let run: Vec<_> = current
.iter()
.copied()
.filter(|id| lifting.contains(id))
.collect();
// Resolved against `rest`, not against `current`: the run has already been
// lifted, so an index into the original list would be off by however many
// of it sat ahead of the target.
let at = match rest.iter().position(|id| *id == target) {
Some(at) if after => at + 1,
Some(at) => at,
None => rest.len(),
};
let mut out = Vec::with_capacity(current.len());
out.extend_from_slice(&rest[..at]);
out.extend(run);
out.extend_from_slice(&rest[at..]);
out
}
/// TRACES: FR-CAT-7
/// The ORDER BY the grid reads `scope` with, and the parameters it binds.
///
/// Manual position where the grid is scoped to a single manual collection with
/// no children; [`GRID_ORDER`] — capture time, then filename — everywhere else.
///
/// **Why the narrowing.** `position` is a column of `collection_members`, so it
/// only exists relative to one collection. A collection *set* shows its
/// descendants' images too, and two children's positions are unrelated integers
/// that would interleave arbitrarily; a smart collection has no member rows to
/// carry a position at all. Outside those cases there is no manual order to
/// read, and falling back is the only honest answer.
///
/// **Why every reader must agree.** An ordinal only names a photograph relative
/// to an ordering. The window read and the span read are two halves of one
/// grid: a shift-click resolved through a different ORDER BY than the cells
/// were drawn with selects a different run than the one on screen, and the user
/// finds out when the export runs. That is the same invariant
/// [`read_ids_span`] already states about `GRID_ORDER`, widened to cover the
/// case where the ordering depends on the scope.
///
/// A correlated subquery rather than a join, so the FROM and WHERE the two
/// readers already share are untouched: position is looked up per row through
/// `collection_members`' primary key, which is `(collection_id, image_id)`.
fn grid_order_for(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
) -> (String, Vec<rusqlite::types::Value>) {
let Some(id) = scope else {
return (GRID_ORDER.to_string(), Vec::new());
};
// A set orders by capture time. `descendants` includes the collection
// itself, so one entry means it has no children.
let alone = dr_catalog::collections::descendants(catalog.connection(), id)
.map(|d| d.len() == 1)
.unwrap_or(false);
let manual = matches!(
dr_catalog::collections::kind(catalog.connection(), id),
Ok(Some(dr_catalog::collections::CollectionKind::Manual))
);
if !alone || !manual {
return (GRID_ORDER.to_string(), Vec::new());
}
// `i.id` breaks the tie. Positions are dense after a `set_order`, but a
// collection that has never been reordered by hand has whatever
// `add_images` assigned, and two rows can share a position if a merge from
// another device brought one in — an ordering that is not total is an
// ordering the window read and the span read can disagree about.
(
"ORDER BY (SELECT cm.position FROM collection_members cm
WHERE cm.collection_id = ? AND cm.image_id = i.id) ASC,
i.id ASC"
.to_string(),
vec![rusqlite::types::Value::Integer(id.0 as i64)],
)
}
/// The SQL restricting a query to `scope` and its descendants, with the bound
/// parameters to go with it.
///
@@ -5510,6 +5664,180 @@ mod tests {
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
}
// --- manual order within a collection (FR-CAT-7) ------------------------
fn ids(n: &[u64]) -> Vec<dr_types::ImageId> {
n.iter().copied().map(dr_types::ImageId).collect()
}
#[test]
fn a_run_moved_forward_lands_before_the_photograph_it_was_dropped_on() {
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[4]), dr_types::ImageId(2), false),
ids(&[1, 4, 2, 3, 5])
);
}
#[test]
fn a_run_moved_backward_lands_before_it_too() {
// The direction of travel must not change what "before this one" means,
// or the same drop would land in two different places depending on
// where the photograph came from.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[2]), dr_types::ImageId(5), false),
ids(&[1, 3, 4, 2, 5])
);
}
#[test]
fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() {
// There is no cell beyond the last one to drop in front of, so without
// `after` the final position is unreachable — which is exactly the
// place a "put this at the end" drag is aiming for.
let current = ids(&[1, 2, 3]);
assert_eq!(
reordered(&current, &ids(&[1]), dr_types::ImageId(3), true),
ids(&[2, 3, 1])
);
}
#[test]
fn a_moved_run_keeps_the_order_the_grid_shows_it_in() {
// Not the order the selection was built in. A user who tapped the last
// frame first has said nothing about how the run should be arranged —
// only about where it should go.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[5, 1]), dr_types::ImageId(3), false),
ids(&[2, 1, 5, 3, 4])
);
}
#[test]
fn a_run_dropped_on_one_of_its_own_members_stays_together() {
// The caller refuses this drop, so it is only reachable if that guard
// is ever lost. It must not lose photographs when it is.
let current = ids(&[1, 2, 3, 4]);
let moved = reordered(&current, &ids(&[2, 3]), dr_types::ImageId(3), false);
assert_eq!(moved.len(), current.len(), "nothing was dropped");
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented");
}
#[test]
fn a_reorder_never_loses_or_duplicates_a_member() {
// The property that matters most: this writes the whole membership
// back, so a run that dropped one image would delete it from the
// collection.
let current = ids(&[1, 2, 3, 4, 5, 6]);
for target in [1u64, 2, 3, 4, 5, 6] {
for after in [false, true] {
let moved = reordered(&current, &ids(&[2, 5]), dr_types::ImageId(target), after);
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(
sorted,
ids(&[1, 2, 3, 4, 5, 6]),
"target {target}, after {after}"
);
}
}
}
/// The scoped grid and the range a shift-click resolves are two halves of
/// one ordering. This is the assertion that keeps them one: an ordinal read
/// through a different ORDER BY names a different photograph, and the user
/// finds out when the export runs.
#[test]
fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() {
let catalog = with_images(5);
let all = image_ids(&catalog);
let id = dr_catalog::collections::create(
catalog.connection(),
"Trip",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap();
// Reversed, so position and capture time disagree about everything.
let wanted: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, wanted, "the grid draws the order that was written");
let spanned =
read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap();
assert_eq!(spanned, wanted, "and a range resolves through the same one");
assert_eq!(
read_member_order(&catalog, id).unwrap(),
wanted,
"and so does the membership a reorder rewrites"
);
}
#[test]
fn a_collection_with_children_falls_back_to_capture_time() {
// A set draws its descendants' images too, and two children's positions
// are unrelated integers. Ordering by them interleaves the two
// arbitrarily, which is worse than an order that at least means
// something.
let catalog = with_images(4);
let all = image_ids(&catalog);
let parent = dr_catalog::collections::create(
catalog.connection(),
"Iceland",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::create(
catalog.connection(),
"Day one",
Some(parent),
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap();
let reversed: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
let cells =
read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, all, "capture time, not the positions that were set");
}
#[test]
fn a_smart_collection_has_no_manual_order_to_read() {
// No member rows at all, so `position` is not a column any of its
// images have. Falling back is the only thing there is to do.
let catalog = with_images(3);
let id = dr_catalog::collections::create(
catalog.connection(),
"Picks",
None,
dr_catalog::collections::CollectionKind::Smart,
)
.unwrap();
let (order, params) = grid_order_for(&catalog, Some(id));
assert_eq!(order, GRID_ORDER);
assert!(params.is_empty());
}
#[test]
fn the_most_recently_trashed_image_is_listed_first() {
// A mistaken delete is corrected within seconds, so the row the user
+109
View File
@@ -4527,6 +4527,13 @@ pub fn wire<F>(
if coll_for_click.press_was_modified() {
return;
}
// TRACES: FR-UI-4
// And a graze is not a tap. The press has already selected the cell,
// so the user sees what they touched; what they do not get is a
// photograph opened by a hand on its way past.
if coll_for_click.press_was_a_graze() {
return;
}
let path = ctl.paths.borrow().get(i as usize).cloned();
if let Some(path) = path {
// Leave the grid for the develop view. The status bar's
@@ -5234,6 +5241,34 @@ pub fn wire<F>(
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_people_listed(move || {
let Some(w) = weak.upgrade() else { return };
push_people_roster(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_filter_person_toggled(move |id| {
let Some(w) = weak.upgrade() else { return };
let person = id.max(0) as u64;
{
let mut f = ctl.filter.borrow_mut();
if let Some(at) = f.people.iter().position(|p| *p == person) {
f.people.remove(at);
} else {
f.people.push(person);
}
}
push_people_chips(&w, &ctl);
refilter(&w, &ctl);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
@@ -5596,6 +5631,9 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
Vec::<PersonChip>::new(),
)));
// The tray, if it is open, has to lose its ticks with the chips: the
// roster carries `picked` and is the same fact drawn a second time.
push_people_roster(window, ctl);
return;
}
@@ -5624,6 +5662,12 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
PersonChip {
id: *id as i32,
name: name.into(),
// Not asked for on this side. The bar's job here is to say who
// the grid is narrowed to and offer a way out of it; a face
// count beside each would be a second number competing with the
// image counts already on the bar.
faces: -1,
picked: true,
}
})
.collect();
@@ -5633,6 +5677,71 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
ctl.filter.borrow().people_mode,
library::PeopleMode::All
));
push_people_roster(window, ctl);
}
/// Fill the filter bar's people tray.
///
/// Rebuilt whole rather than patched, because `picked` is on every row and a
/// toggle changes two things at once — the chip that was pressed and, under
/// `All`, what the whole filter means.
///
/// **Named people first, then by how much of them the library holds.** The
/// catalog orders by face count alone, which on a real library puts a dozen
/// unnamed strangers ahead of the two people the user has actually named — and
/// the tray scrolls horizontally, so anything past the first few chips costs a
/// gesture to reach. Naming somebody is the user saying they matter; the order
/// says it back.
fn push_people_roster(window: &AppWindow, ctl: &Rc<LibraryController>) {
let picked = ctl.filter.borrow().people.clone();
let borrow = ctl.catalog.borrow();
let people = borrow
.as_ref()
.and_then(|cat| dr_catalog::faces::people(cat.connection()).ok())
.unwrap_or_default();
// Sorted as (unnamed, -faces) pairs beside the chip rather than by
// re-reading the drawn label: "is this person named" is a fact about the
// record, and recovering it from the `Unnamed (n faces)` wording would put
// a sort key inside a string meant for a human to read.
let mut rows: Vec<(bool, i64, PersonChip)> = people
.iter()
.filter(|p| {
let on = picked.contains(&p.id.0);
// A person already in the filter always has a chip, whatever else
// is true of them: the tray is where the filter is taken apart, and
// a term with no control is a term the user cannot remove.
//
// Otherwise: nobody the user set aside, and nobody with no faces —
// a named person emptied by a split would be a chip that narrows
// the grid to nothing whatever else is on the bar.
on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0)
})
.map(|p| {
let faces = p.confirmed_faces + p.suggested_faces;
let unnamed = p.name.trim().is_empty();
(
unnamed,
faces as i64,
PersonChip {
id: p.id.0 as i32,
name: if unnamed {
format!("Unnamed ({faces} faces)").into()
} else {
p.name.clone().into()
},
faces: faces as i32,
picked: picked.contains(&p.id.0),
},
)
})
.collect();
// Stable, so the catalog's own tiebreak by name survives inside each of the
// two blocks.
rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces));
let rows: Vec<PersonChip> = rows.into_iter().map(|(_, _, chip)| chip).collect();
window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows)));
}
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
+7 -1
View File
@@ -111,7 +111,13 @@ impl SettingsController {
/// accidentally write back a stale copy of the fields it was not editing —
/// with a save on every keystroke, two controls holding their own snapshots
/// would overwrite each other.
fn edit(&self, f: impl FnOnce(&mut Settings)) {
///
/// Public because the settings page is no longer the only screen that edits
/// this record: the People screen carries the grouping dials, for the reason
/// identity.slint gives, and they are the same per-device preferences saved
/// to the same file. Everything that writes settings comes through here, so
/// the sanitise-and-save discipline holds wherever the control lives.
pub fn edit(&self, f: impl FnOnce(&mut Settings)) {
{
let mut settings = self.settings.borrow_mut();
f(&mut settings);
+42 -5
View File
@@ -309,7 +309,16 @@ export component AppWindow inherits Window {
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-collection-from-selection();
callback library-select-all();
/// TRACES: FR-CAT-7
/// Whether the grid is showing something with a manual order to change —
/// a single manual collection, not a set and not a saved filter.
in property <bool> library-reorderable: false;
/// Move the selection beside the photograph at this row of the loaded
/// window: after it where the drop landed on the cell's trailing half,
/// before it otherwise.
callback library-reorder-to(int, bool);
callback library-collection-from-selection(string);
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
@@ -410,6 +419,15 @@ export component AppWindow inherits Window {
callback identity-recluster();
in property <bool> identity-regrouping: false;
in property <string> identity-regroup-status;
// The grouping dials Regroup turns. Percent and a face count, both stored
// in the device settings file beside the cache budgets.
in property <float> identity-merge-probability: 80;
in property <int> identity-min-group-size: 2;
callback identity-merge-probability-changed(float);
callback identity-min-group-size-changed(int);
callback identity-preview-grouping();
in property <bool> identity-previewing: false;
in property <string> identity-grouping-preview;
in property <int> identity-ignored-count: 0;
in property <bool> identity-show-ignored: false;
in property <bool> identity-selected-ignored: false;
@@ -566,7 +584,7 @@ export component AppWindow inherits Window {
callback library-drag-finished();
in property <int> library-selected-count: 0;
callback library-cell-pressed(int, bool, bool);
callback library-cell-pressed(int, bool, bool, bool);
callback library-remove-from-collection();
// The keyboard cursor: where the arrows are in the library, as an image
@@ -599,6 +617,10 @@ export component AppWindow inherits Window {
in property <bool> library-filter-people-all: false;
callback library-filter-person-cleared(int);
callback library-filter-people-mode-toggled();
/// Everyone the library knows, for the filter bar's people tray.
in property <[PersonChip]> library-people;
callback library-people-listed();
callback library-filter-person-toggled(int);
in property <int> library-filter-min-rating: 0;
in property <bool> library-filter-unjudged: false;
in property <int> library-filter-flag: 0;
@@ -1299,6 +1321,13 @@ in property <bool> panel-visible: true;
photos-filtered: root.library-filter-people.length > 0;
regrouping: root.identity-regrouping;
regroup-status: root.identity-regroup-status;
merge-probability: root.identity-merge-probability;
min-group-size: root.identity-min-group-size;
merge-probability-changed(v) => { root.identity-merge-probability-changed(v); }
min-group-size-changed(n) => { root.identity-min-group-size-changed(n); }
previewing: root.identity-previewing;
grouping-preview: root.identity-grouping-preview;
preview-grouping() => { root.identity-preview-grouping(); }
ignored-count: root.identity-ignored-count;
show-ignored: root.identity-show-ignored;
selected-ignored: root.identity-selected-ignored;
@@ -1429,7 +1458,12 @@ in property <bool> panel-visible: true;
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
collection-from-selection() => { root.library-collection-from-selection(); }
select-all() => { root.library-select-all(); }
reorderable: root.library-reorderable;
reorder-to(row, after) => { root.library-reorder-to(row, after); }
collection-from-selection(name) => {
root.library-collection-from-selection(name);
}
range-from: root.library-range-from;
range-to: root.library-range-to;
range-invalid: root.library-range-invalid;
@@ -1511,8 +1545,8 @@ in property <bool> panel-visible: true;
open-settings() => { root.settings-open(); }
open-people() => { root.identity-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
cell-pressed(i, ctrl, shift, touch) => {
root.library-cell-pressed(i, ctrl, shift, touch);
}
cell-press-ended() => { root.library-cell-press-ended(); }
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
@@ -1543,6 +1577,7 @@ in property <bool> panel-visible: true;
filter-people: root.library-filter-people;
filter-people-all: root.library-filter-people-all;
people: root.library-people;
filter-min-rating: root.library-filter-min-rating;
filter-unjudged: root.library-filter-unjudged;
filter-flag: root.library-filter-flag;
@@ -1576,6 +1611,8 @@ in property <bool> panel-visible: true;
filter-flag-changed(f) => { root.library-filter-flag-changed(f); }
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
people-listed() => { root.library-people-listed(); }
filter-person-toggled(id) => { root.library-filter-person-toggled(id); }
}
}
}
+131 -1
View File
@@ -16,6 +16,7 @@
import { Theme } from "theme.slint";
import { Panel, Button, IconButton, Field } from "widgets.slint";
import { SliderRow } from "controls.slint";
import { Icon } from "icons.slint";
export struct IdentityPerson {
@@ -235,6 +236,11 @@ export component IdentityScreen inherits Rectangle {
/// live and has to say what it is doing rather than simply stopping.
in property <bool> regrouping: false;
in property <string> regroup-status;
/// Whether the grouping dials are open.
///
/// Private to the screen: nothing in Rust needs to know, and a disclosure
/// state that round-tripped through a callback would flicker.
property <bool> grouping-open: false;
/// People the user has set aside, and whether the rail is showing them.
in property <int> ignored-count: 0;
in property <bool> show-ignored: false;
@@ -251,6 +257,21 @@ export component IdentityScreen inherits Rectangle {
/// "and also" button only appears when there is something to add to.
in property <bool> photos-filtered: false;
callback recluster();
/// TRACES: FR-CULL-9
/// How sure the pass has to be before it calls two groups one person, as a
/// percentage — the same units every confidence on this screen is printed
/// in, because a control in *cosines* would be the one place in the
/// subsystem that thresholds a bare similarity.
in property <float> merge-probability: 80;
/// The smallest group the pass will make a person out of.
in property <int> min-group-size: 2;
callback merge-probability-changed(float);
callback min-group-size-changed(int);
/// Ask what the dials would do, without doing it.
callback preview-grouping();
in property <bool> previewing: false;
/// The answer, in words. Empty until one has been asked for.
in property <string> grouping-preview;
callback index-faces();
callback stop-indexing();
callback check-coverage();
@@ -433,7 +454,16 @@ export component IdentityScreen inherits Rectangle {
: 0px;
visible: root.selected-person >= 0;
edited(t) => { root.name-edited(t); }
accepted(t) => { root.rename(t); }
// Enter *finishes* naming this person, so the field lets
// the keyboard go with it. Without this the entry keeps
// focus after the name is committed, and on a tablet the
// on-screen keyboard stays up over the faces the user
// pressed Enter to get back to — the name looks accepted
// and the screen looks stuck.
accepted(t) => {
root.rename(t);
name-field.release-focus();
}
}
if root.selected-person < 0: Text {
text: "Select a person";
@@ -521,6 +551,106 @@ export component IdentityScreen inherits Rectangle {
enabled: !root.regrouping;
clicked => { root.recluster(); }
}
// The dials Regroup turns, immediately beside it. They belong
// here and not on the settings page: they are only meaningful
// next to the button that applies them and the rail that shows
// what they did, and a value changed three screens away from
// its effect is a value nobody can tune.
Button {
text: "Grouping…";
active: root.grouping-open;
clicked => { root.grouping-open = !root.grouping-open; }
}
}
}
// --- the grouping dials ---------------------------------------
//
// Opened inline rather than in a popup, the way the develop
// column's film picker is: this screen already scrolls as one, and
// a second overlay to dismiss is a second gesture to lose.
if root.grouping-open: Rectangle {
border-radius: Theme.radius;
background: Theme.surface-raised;
// Stated, because the layout above it is a VerticalLayout that
// would otherwise stretch this block over the faces grid.
height: dials.preferred-height + 2 * Theme.gap;
dials := VerticalLayout {
x: Theme.gap;
y: Theme.gap;
width: parent.width - 2 * Theme.gap;
spacing: Theme.gap-sm;
// **The hints are two words each, and that is deliberate.**
// `FieldRow` draws a hint as a non-wrapping elided
// `Caption`, whose *minimum* width is still its whole text
// — and a layout cannot be narrower than its children's
// minimums, so a sentence here would set the minimum width
// of this pane and, on a phone, of the screen. The
// sentences go in the wrapping paragraph below instead,
// where they cost nothing.
SliderRow {
label: "Match confidence";
hint: "%";
value: root.merge-probability;
default-value: 80;
minimum: 50;
maximum: 95;
precision: 0;
enabled: !root.regrouping;
changed(v) => { root.merge-probability-changed(v); }
reset => { root.merge-probability-changed(80); }
}
SliderRow {
label: "Smallest group";
hint: "faces";
value: root.min-group-size;
default-value: 2;
minimum: 1;
maximum: 12;
precision: 0;
enabled: !root.regrouping;
changed(v) => { root.min-group-size-changed(v); }
reset => { root.min-group-size-changed(2); }
}
// The dial is otherwise blind: nothing on the screen says
// what 78% means for *this* library until the user commits
// to a pass and reads the rail. `dr_face`'s tuning table is
// the right answer to that question and it is measured on
// somebody else's photographs, so this runs it here — one
// row of it, read-only, for the value actually set.
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Button {
text: root.previewing ? "Working…" : "What would this do?";
enabled: !root.previewing && !root.regrouping;
clicked => { root.preview-grouping(); }
}
if root.grouping-preview != "": Text {
text: root.grouping-preview;
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
wrap: word-wrap;
horizontal-stretch: 1;
}
}
// What the two dials mean, and — said plainly, because
// they look destructive and are not — what they cannot
// touch: they move the *suggested* half, which this pass
// owns and may revise as often as it likes.
Text {
text: "Lower confidence gathers more of each person together; too low and different people are welded into one. A bigger smallest group keeps one-off strangers off the rail.\n\nPress Regroup to apply. Names, confirmations and the groups you have set aside are kept whatever the dials say.";
color: Theme.ink-faint;
font-size: Theme.text-sm;
wrap: word-wrap;
}
}
}
+451 -11
View File
@@ -414,10 +414,20 @@ export component Timeline inherits Rectangle {
}
}
/// One person the grid is narrowed to, as drawn on the filter bar.
/// One person on the filter bar, in either of the two roles it plays there.
///
/// The chips for who the grid is *currently* narrowed to, and the roster in the
/// tray the user picks from, are the same thing drawn twice — same id, same
/// wording — so they are one struct. `faces` and `picked` are the tray's half
/// and are simply not read by the narrowed-to chips.
export struct PersonChip {
id: int,
name: string,
/// How many faces the library holds of them. `-1` where it was not asked
/// for, which `FilterChip` draws as no number at all rather than as zero.
faces: int,
/// Already one of the people the grid is narrowed to.
picked: bool,
}
export struct LibraryCell {
@@ -1230,7 +1240,15 @@ export component LibraryGrid inherits Rectangle {
// also why there is no badge position to compute here any more.
/// Modifier state at press time, so Rust can decide replace / add / extend
/// without the .slint file encoding the selection policy.
callback cell-pressed(int, bool, bool);
///
/// The last argument is **whether a finger did it**, and it is here because
/// a finger and a pointer need different rules about what counts as a tap.
/// A mouse click is over in tens of milliseconds and means it; a hand
/// brushing a tablet on its way somewhere else produces exactly the same
/// press-and-release, and used to land the user in develop. Rust holds the
/// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit
/// between — this file only reports which kind of contact it was.
callback cell-pressed(int, bool, bool, bool);
/// TRACES: FR-UI-2 | FR-UI-4
/// The press on a cell ended — lifted, or taken away by the Flickable when
/// the finger travelled. Cancels the long-press timer that would otherwise
@@ -1255,11 +1273,76 @@ export component LibraryGrid inherits Rectangle {
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-5
// --- reordering a manual collection (FR-CAT-7) --------------------------
//
// `collection_members.position` and `Sort::CollectionPosition` have existed
// in the catalog since collections did, and nothing above it ever wrote or
// read them: the grid ordered everything by capture time, always. This is
// the gesture that makes the column mean something.
//
// Only where there is a manual order to change — a single manual collection
// with no children. A set interleaves two children's unrelated positions
// and a smart collection has no member rows at all, so both fall back to
// capture time and refuse the drop rather than pretending.
/// Whether a drop on a cell should move photographs within the collection
/// being shown. Set by Rust from the scope, because what a scope *is* is a
/// catalog question.
in property <bool> reorderable: false;
/// Move the selection so it sits beside the photograph at this row of the
/// loaded window — before it, or after it where the drop landed on the
/// cell's trailing half. The trailing half is what makes the last position
/// reachable at all; without it there is no cell to drop "before".
callback reorder-to(int, bool);
/// Drop the selection without leaving select mode.
callback clear-selection();
/// TRACES: FR-CAT-5 | FR-UI-4
/// Take everything the grid is currently showing — the whole library, or
/// the whole of whatever it is scoped and filtered to. Answered from the
/// catalog, not from the loaded window, for the reason `cell-pressed`'s
/// shift argument is: what is on screen is a fraction of what is meant.
callback select-all();
/// TRACES: FR-UI-2 | FR-UI-4
/// Whether the next cell tap should take everything from the last cell
/// tapped to it.
///
/// **Why this exists.** Touch has had a range gesture for as long as
/// selection mode has — double-tap the far end — and it was a gesture only
/// its author could find. It is invisible, it is unreliable on a grid that
/// scrolls under the second tap, and it extends from the anchor *before*
/// the two taps moved it, a rule subtle enough to need two paragraphs of
/// Rust to explain to itself.
///
/// This is the same operation made visible: a button that arms it, a strip
/// that says what the next tap will do, and a way out. It also does the one
/// thing a drag-to-select sweep could not — the user may scroll as far as
/// they like between the two taps, and the run is resolved by the catalog
/// rather than by what happens to be on screen. The ranges that hurt on a
/// tablet are longer than a screenful, which is exactly where a sweep runs
/// out.
///
/// Local to this file, and one-shot: the next press consumes it. Rust needs
/// no state for it, because it arrives as `cell-pressed`'s shift argument
/// and lands in `apply_press` as the shift-click it already knows how to
/// apply.
property <bool> ranging: false;
// A range armed against nothing has no anchor to extend from, and the strip
// that says it is armed is only drawn while there is a selection — so
// "Done" in the header, which drops the selection from outside that strip,
// would leave the mode armed and invisible, and the next ordinary tap would
// take a run the user never asked for.
changed selected-count => {
if (root.selected-count == 0) {
root.ranging = false;
}
}
/// TRACES: FR-CAT-5
/// Make a new collection holding exactly what is selected.
callback collection-from-selection();
/// Make a new collection, under the given name, holding exactly what is
/// selected. The name arrives from the sheet below rather than being
/// invented by Rust and corrected afterwards — see `naming`.
callback collection-from-selection(string);
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
/// drag starts, so it always reflects the selection as it is at that moment.
pure callback drag-payload() -> data-transfer;
@@ -1294,6 +1377,24 @@ export component LibraryGrid inherits Rectangle {
/// Whose photographs the grid is narrowed to — one chip each, so a
/// selection of three can be taken apart one person at a time.
in property <[PersonChip]> filter-people;
/// Everyone the library knows, for the tray. Filled on demand — see the
/// tray itself for why it is not simply pushed alongside the chips.
in property <[PersonChip]> people;
/// Asked for when the tray opens, so a roster is never built for a bar
/// nobody has opened and never goes stale in one that is.
callback people-listed();
/// Add or remove one person from the filter, keeping the rest.
///
/// The gesture the whole tray exists for: `filter-person-cleared` can only
/// take somebody *out*, so before this the only way to narrow to two people
/// at once was to visit the People screen twice.
callback filter-person-toggled(int);
/// Whether the people tray is open.
///
/// Private to the view, like every other disclosure here: nothing in Rust
/// needs to know, and a strip whose open state round-tripped through a
/// callback would flicker on every press.
property <bool> people-tray: false;
/// Whether those people are an intersection rather than a union.
in property <bool> filter-people-all: false;
callback filter-person-cleared(int);
@@ -1458,6 +1559,26 @@ export component LibraryGrid inherits Rectangle {
/// disclosure rather than a preference, and what closes it is dismissing it.
property <bool> keywording: false;
// --- naming a new collection (FR-CAT-5, FR-CAT-7) -----------------------
//
// "New collection from selection" used to create the collection under a
// placeholder name and then open the rename field in the sidebar tree.
//
// On a tablet the sidebar is not on screen. It is instantiated all the
// same — `app.slint` collapses it to zero width and `visible: false`
// rather than using an `if`, because an `if` there is a layout loop Slint
// panics on — so the rename field was created, its `init` took focus, and
// Android raised the on-screen keyboard for a box nobody could see. Nothing
// else on the screen is focusable, so the keyboard had nowhere to go: it
// stayed, the name could not be typed, and the collection was already
// written under the name the user did not want.
//
// Asked here instead, before anything is written. A sheet dismissed leaves
// no collection behind, which the create-then-rename order could not
// promise.
/// Whether the naming sheet is up. Local, like `filing` and `keywording`.
property <bool> naming: false;
// Cell geometry. Columns are derived from the available width so the grid
// reflows with the window rather than fixing a count (FR-UI-1).
// Zoomable, so the grid serves both jobs: fewer, larger images for
@@ -1922,6 +2043,38 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.filter-people-mode-toggled(); }
}
// The way in to the people tray, and the reason it exists.
//
// Narrowing to *two* people at once was already possible and
// effectively unreachable: the only control that could add a
// second one lived on the People screen, behind selecting them
// there, and it only appeared once the grid was already
// narrowed to somebody. So a user who wanted "photographs with
// both of them" had to guess a two-screen round trip. A filter
// belongs on the filter bar; this chip is the whole feature's
// front door and the tray below is where both terms and the
// any/all choice actually are.
FilterChip {
icon: root.people-tray ? "chevron-down" : "chevron-right";
label: "People";
// The number narrowed to, not the size of the roster: it
// says what the filter is doing, which is what every other
// count on this bar says.
count: root.filter-people.length > 0
? root.filter-people.length : -1;
active: root.people-tray;
y: (parent.height - self.height) / 2;
clicked => {
root.people-tray = !root.people-tray;
// Asked for on open rather than kept in step, so the
// roster is current — indexing and regrouping change
// who exists, and a list cached at startup would be
// stale for exactly the user who has just been naming
// people.
if (root.people-tray) { root.people-listed(); }
}
}
Caption {
text: "Show";
vertical-alignment: center;
@@ -2046,6 +2199,76 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- the people tray ----------------------------------------------
//
// A second strip under the filter bar rather than a popup, for the
// reason the develop column's film picker gives: this view already
// scrolls as one, so an inline strip is taller content and not a second
// overlay with its own dismiss gesture to lose a drag to.
//
// Horizontally scrolling, exactly like the bar above it and for the
// same hard reason — **a layout cannot be narrower than its children's
// minimums**, and a library with forty people would otherwise report a
// minimum width of forty chips and inflate the whole view. See the bar
// above for the full account of that fault.
if root.people-tray: Rectangle {
height: 38px;
background: Theme.surface;
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, people-row.preferred-width);
people-row := HorizontalLayout {
width: parent.viewport-width;
height: parent.viewport-height;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: 4px;
alignment: start;
Caption {
// Says what a *pair* of chips will mean before either
// is pressed, which is the thing the old design never
// said anywhere.
text: root.filter-people-all
? "In every one:" : "In the picture:";
vertical-alignment: center;
}
// The roster. Ordered most-photographed-first with the
// named ahead of the rest, so the people a user actually
// intersects are the ones under the thumb without
// scrolling.
for p[i] in root.people: FilterChip {
icon: p.picked ? "check" : "";
label: p.name;
count: p.faces;
active: p.picked;
y: (parent.height - self.height) / 2;
clicked => { root.filter-person-toggled(p.id); }
}
// Not an error and not empty chrome: face indexing is an
// opt-in overnight pass, so "nobody yet" is the ordinary
// state of a library nobody has run it on, and it should
// say where the pass lives.
if root.people.length == 0: Caption {
text: "Nobody indexed yet — find faces on the People screen.";
vertical-alignment: center;
}
}
}
}
// --- what is selected, and what to do with it --------------------
//
// The count and these actions used to live only in the header row,
@@ -2070,9 +2293,33 @@ export component LibraryGrid inherits Rectangle {
spacing: Theme.gap-sm;
alignment: start;
// While a range is armed the strip stops reporting and starts
// instructing. The count is still true, but it is not what the
// user needs to read: they have pressed something that changes
// what the *next* tap means, and a mode with no visible state
// is the double-tap gesture this replaces.
if root.ranging: Value {
text: "Tap the last photograph";
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
if root.ranging: Rectangle { width: Theme.gap; }
// Armed by mistake, or armed and thought better of. Without
// this the only way out is to tap a cell, which takes a run the
// user did not want and then has to be undone by hand.
if root.ranging: Button {
text: "Cancel";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = false; }
}
// State rather than a label: it is what the buttons beside it
// act on.
Value {
if !root.ranging: Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
@@ -2081,26 +2328,50 @@ export component LibraryGrid inherits Rectangle {
overflow: elide;
}
Rectangle { width: Theme.gap; }
if !root.ranging: Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
Button {
if !root.ranging: Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Everything the grid is showing. Cheap to offer and tedious to
// do by hand: a scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those three" is a far
// more common shape than the taps it took to say it.
if !root.ranging: Button {
text: "Select all";
y: (parent.height - self.height) / 2;
clicked => { root.select-all(); }
}
// The visible half of `ranging` — see its declaration for why
// the double-tap it replaces was not good enough. Deliberately
// an unfinished sentence: the ellipsis is the promise that a
// second tap is coming.
if !root.ranging: Button {
text: "Select to…";
y: (parent.height - self.height) / 2;
clicked => { root.ranging = true; }
}
// Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
// a selection is usually meant.
Button {
text: "New collection from selection";
// No longer "…from selection": the sheet it opens says
// "New collection holding 12 photographs" at the top, so a
// button repeating it is width the strip does not have on a
// tablet in portrait.
if !root.ranging: Button {
text: "New collection";
primary: true;
y: (parent.height - self.height) / 2;
clicked => { root.collection-from-selection(); }
clicked => { root.naming = true; }
}
}
}
@@ -2680,6 +2951,41 @@ export component LibraryGrid inherits Rectangle {
}
drag-finished(action) => { root.drag-finished(); }
// Where a reorder lands. Behind the cell's content and
// before it in the file, for the reason `TreeRow`'s drop
// area gives: a `DropArea` only takes part in a drag, so it
// does not block the presses the cell's own TouchArea
// needs — but drawn first it cannot paint over the
// thumbnail either.
//
// Present on every cell rather than wrapped in an `if`, so
// the marker below can name it. `can-drop` is where the
// refusal lives, which also means the cursor says no while
// the user can still aim somewhere else.
reorder-drop := DropArea {
width: 100%;
height: 100%;
/// Whether the run would land after this photograph
/// rather than before it. Tracked during the hover so
/// the marker can move to the edge the drop will
/// actually use.
property <bool> after: false;
can-drop(ev) => {
if (!root.reorderable) {
return DragAction.none;
}
self.after = ev.position.x > self.width / 2;
return DragAction.copy;
}
dropped(ev) => {
root.reorder-to(i, ev.position.x > self.width / 2);
return DragAction.copy;
}
}
Rectangle {
// Lifted cells shrink toward their own centre, as though pulled
// off the page. Inset rather than scaled: Slint has no transform
@@ -2847,11 +3153,27 @@ export component LibraryGrid inherits Rectangle {
// over from the gesture, handed back as a new
// press. See `pinching`.
if (!root.pinching) {
// An armed range reaches Rust as shift,
// which is what it is: `apply_press` reads
// ctrl+shift as "add the run from the
// anchor to here", and in selection mode
// ctrl is already set. Sent this way rather
// than as a third selection policy, so the
// rules stay in one place.
root.cell-pressed(
i,
ev.modifiers.control || root.select-mode,
ev.modifiers.shift,
ev.modifiers.shift || root.ranging,
// Finger id 0 is the mouse — the same
// convention the pinch arbitration a
// few lines up already relies on.
ev.touch-finger-id != 0,
);
// One shot. The range was between two taps
// and the second has landed; left armed, it
// would turn every tap after it into
// another run.
root.ranging = false;
}
}
if (ev.kind == PointerEventKind.up) {
@@ -3011,6 +3333,28 @@ export component LibraryGrid inherits Rectangle {
}
}
}
// Where the run would land. A bar in the gutter beside the
// cell it would sit next to, on whichever side the drop
// will actually use — the trailing edge is what makes the
// last place in a collection reachable, and a marker that
// did not move with it would be pointing at the wrong gap
// half the time.
//
// Last child of the `DragArea`, so it draws over the
// thumbnail rather than under it. In the gutter rather than
// on the cell, because a bar drawn *on* the first cell of a
// row reads as belonging to that cell instead of to the
// space before it.
if reorder-drop.has-drag: Rectangle {
x: reorder-drop.after
? parent.width + Theme.gap / 2 - 1.5px
: -Theme.gap / 2 - 1.5px;
y: 0;
width: 3px;
height: parent.height;
background: Theme.active;
border-radius: 1.5px;
}
}
}
@@ -3343,4 +3687,100 @@ export component LibraryGrid inherits Rectangle {
}
}
}
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
//
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
// above. The short version is that on a tablet the sidebar is instantiated
// but not drawn, so the rename field could take the keyboard without ever
// being visible.
//
// The same card, scrim and dismissal as the two sheets above it, for the
// same reason they share one: a user who has filed a selection knows how
// this works.
if root.naming: Rectangle {
background: #000000CC;
// Swallows the taps that miss the card, and closes. First, so the
// card's own controls sit above it.
TouchArea {
clicked => { root.naming = false; }
}
Rectangle {
width: min(420px, parent.width - 2 * Theme.gap-lg);
height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg);
x: (parent.width - self.width) / 2;
// A third of the way down, not centred. The field below takes the
// keyboard as the sheet appears, and on a tablet the keyboard is
// the bottom half of the window — a card centred in the window is a
// card centred behind it.
y: max(Theme.gap-lg, (parent.height - self.height) / 3);
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
// Stops a press on the card reaching the scrim behind it.
TouchArea { }
name-sheet := VerticalLayout {
padding: Theme.gap-lg;
spacing: Theme.gap;
Text {
text: root.selected-count == 1
? "New collection holding 1 photograph"
: "New collection holding " + root.selected-count
+ " photographs";
color: Theme.ink;
font-size: Theme.text-lg;
font-weight: 600;
wrap: word-wrap;
}
name-field := Field {
placeholder: "Name this collection";
// The card asks one question, so the field answers the
// keyboard for it. This is also what raises the on-screen
// keyboard on Android — over a field that is on screen,
// which is the whole difference from the old path.
init => { self.take-focus(); }
// Return commits, as it does in every other field here.
// Guarded rather than trusting `enabled` on the button
// beside it: this is a second way in and it must refuse an
// empty name on its own.
accepted(text) => {
if (text != "") {
root.collection-from-selection(text);
root.naming = false;
}
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: end;
Button {
text: "Cancel";
clicked => { root.naming = false; }
}
Button {
text: "Create";
primary: true;
// A collection called "New collection" is the state
// this sheet exists to prevent, so Create waits for a
// name rather than inventing one.
enabled: name-field.text != "";
clicked => {
root.collection-from-selection(name-field.text);
root.naming = false;
}
}
}
}
}
}
}
+29
View File
@@ -617,6 +617,35 @@ export component Field inherits Rectangle {
/// because the breakage is in Slint's binding model, not the styling.
callback edited(string);
/// Take the keyboard, and select what is already there.
///
/// For a sheet whose field is the only thing to do in it: the field arrives
/// with the sheet, nothing else on the card can sensibly hold focus, and
/// asking the user to tap a box that is the only box is a step with no
/// decision in it. On a tablet it is also what raises the on-screen
/// keyboard, which is the actual point.
///
/// A function rather than a property, because focus is an event and not a
/// state: bound to a property it would fight anything else that took focus
/// afterwards, and re-take it on every unrelated re-evaluation.
public function take-focus() {
input.focus();
input.select-all();
}
/// Give the keyboard back.
///
/// The other half of `take-focus`, and the one a field that *submits*
/// needs: pressing Enter on a name has finished with the name, but Slint
/// leaves the entry focused, so on a tablet the on-screen keyboard stays
/// up covering the very thing the user just named. Nothing else on those
/// screens takes focus on its own, so the field has to let go itself.
///
/// A function and not a property, for the reason `take-focus` gives.
public function release-focus() {
input.clear-focus();
}
height: Theme.touch-target;
border-radius: Theme.radius;
border-width: 1px;