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:
@@ -477,6 +477,23 @@ pub fn collections_for_image(
|
|||||||
Ok(rows)
|
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.
|
/// A collection and everything beneath it, including itself.
|
||||||
///
|
///
|
||||||
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub struct Settings {
|
|||||||
pub develop: DevelopSettings,
|
pub develop: DevelopSettings,
|
||||||
pub import: ImportSettings,
|
pub import: ImportSettings,
|
||||||
pub library: LibrarySettings,
|
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
|
// Import
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -884,6 +974,21 @@ impl Settings {
|
|||||||
pub fn sanitise(&mut self) {
|
pub fn sanitise(&mut self) {
|
||||||
self.export.quality = self.export.quality.clamp(1, 100);
|
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
|
// Snapped to an offered count rather than clamped to a range. The
|
||||||
// settings page lights the chip whose value matches, so a
|
// settings page lights the chip whose value matches, so a
|
||||||
// hand-edited 40 would leave every chip dark and the page unable to
|
// 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());
|
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]
|
#[test]
|
||||||
fn sanitise_leaves_a_real_destination_alone() {
|
fn sanitise_leaves_a_real_destination_alone() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings::default();
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## 10. Catalog and jobs
|
||||||
|
|||||||
+44
-44
File diff suppressed because one or more lines are too long
@@ -88,7 +88,11 @@ fn main() {
|
|||||||
// whole-library operation over the embeddings detection produced, and it is
|
// whole-library operation over the embeddings detection produced, and it is
|
||||||
// worth running *after* a sweep rather than during one (catalog.md §10.2).
|
// worth running *after* a sweep rather than during one (catalog.md §10.2).
|
||||||
if args.iter().any(|a| a == "--cluster") {
|
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)) => {
|
Ok((suggested, created)) => {
|
||||||
println!("\nclustering: {suggested} suggestion(s), {created} new group(s)");
|
println!("\nclustering: {suggested} suggestion(s), {created} new group(s)");
|
||||||
report_people(&catalog);
|
report_people(&catalog);
|
||||||
|
|||||||
+303
-10
@@ -43,6 +43,7 @@ use dr_types::{CollectionId, ImageId};
|
|||||||
use rusqlite::OptionalExtension as _;
|
use rusqlite::OptionalExtension as _;
|
||||||
use slint::{ComponentHandle, Model as _};
|
use slint::{ComponentHandle, Model as _};
|
||||||
|
|
||||||
|
use crate::library;
|
||||||
use crate::{AppWindow, CollectionRow};
|
use crate::{AppWindow, CollectionRow};
|
||||||
|
|
||||||
/// The selection as it stood before a press, for [`cancel_press`].
|
/// 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
|
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
||||||
/// `clicked`, so the press records them and the click consults this.
|
/// `clicked`, so the press records them and the click consults this.
|
||||||
modified_press: std::cell::Cell<bool>,
|
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
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// Whether a tap in the grid selects rather than opens.
|
/// Whether a tap in the grid selects rather than opens.
|
||||||
///
|
///
|
||||||
@@ -332,6 +343,38 @@ impl CollectionsController {
|
|||||||
self.modified_press.get()
|
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.
|
/// Selected image ids, in a stable order.
|
||||||
pub fn selected(&self) -> Vec<ImageId> {
|
pub fn selected(&self) -> Vec<ImageId> {
|
||||||
self.selection.borrow().iter().copied().collect()
|
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_smart.borrow_mut() = smart;
|
||||||
*ctl.row_has_children.borrow_mut() = has_kids;
|
*ctl.row_has_children.borrow_mut() = has_kids;
|
||||||
window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out)));
|
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
|
/// 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);
|
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.
|
/// Refresh the per-cell "in this many collections" badges.
|
||||||
///
|
///
|
||||||
/// One query for the whole window rather than one per cell: 120 cells is 120
|
/// 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.
|
/// the hand lets go first, having concluded nothing was going to happen.
|
||||||
pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
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
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// Start the timer that turns a held cell into a selection.
|
/// 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 weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
let visible = visible_ids.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 Some(w) = weak.upgrade() else { return };
|
||||||
let ids = visible();
|
let ids = visible();
|
||||||
|
|
||||||
// Consulted by the click that follows: a modified press is building
|
// Consulted by the click that follows: a modified press is building
|
||||||
// a selection and must not also navigate to develop.
|
// a selection and must not also navigate to develop.
|
||||||
ctl.modified_press.set(ctrl_held || shift_held);
|
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
|
// 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
|
// 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(¤t, &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
|
// TRACES: FR-CAT-5
|
||||||
// A collection holding exactly what is selected.
|
// 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
|
// the tree's "+". A selection can be gathered from anywhere, including
|
||||||
// across collections, so filing it under whichever one happens to be open
|
// across collections, so filing it under whichever one happens to be open
|
||||||
// would put it somewhere its contents did not come from.
|
// 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 weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
let catalog = catalog.clone();
|
let catalog = catalog.clone();
|
||||||
let visible = visible_ids.clone();
|
let visible = visible_ids.clone();
|
||||||
let reload = on_scope_changed.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 Some(w) = weak.upgrade() else { return };
|
||||||
let images = ctl.selected();
|
let images = ctl.selected();
|
||||||
if images.is_empty() {
|
if images.is_empty() {
|
||||||
@@ -1605,12 +1840,24 @@ pub fn wire<S, R, P, C>(
|
|||||||
return;
|
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)
|
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 {
|
match made {
|
||||||
Ok((id, added)) => {
|
Ok(added) => {
|
||||||
w.set_collection_error(slint::SharedString::new());
|
w.set_collection_error(slint::SharedString::new());
|
||||||
w.set_library_status(
|
w.set_library_status(
|
||||||
format!(
|
format!(
|
||||||
@@ -1628,11 +1875,10 @@ pub fn wire<S, R, P, C>(
|
|||||||
ctl.clear_selection();
|
ctl.clear_selection();
|
||||||
sync_selection(&w, &ctl, &visible());
|
sync_selection(&w, &ctl, &visible());
|
||||||
|
|
||||||
// Into the name field, for the same reason `collection_new`
|
// No rename opened here. The name was chosen before the
|
||||||
// does it: "New collection" is a placeholder nobody wants
|
// collection existed, so there is nothing left to correct —
|
||||||
// to keep, and making them find the rename afterwards is
|
// and opening the sidebar's field is what stranded the
|
||||||
// asking them to finish a job we started.
|
// keyboard on a tablet.
|
||||||
w.set_collection_renaming(id.0 as i32);
|
|
||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -2164,6 +2410,7 @@ pub fn wire<S, R, P, C>(
|
|||||||
};
|
};
|
||||||
w.set_collection_selected(id);
|
w.set_collection_selected(id);
|
||||||
w.set_collection_scope_label(label.into());
|
w.set_collection_scope_label(label.into());
|
||||||
|
sync_reorderable(&w);
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2554,6 +2801,52 @@ mod tests {
|
|||||||
Vec::new()
|
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]
|
#[test]
|
||||||
fn dragging_a_collection_onto_another_moves_it() {
|
fn dragging_a_collection_onto_another_moves_it() {
|
||||||
// The gesture the tree rearrangement exists for: no images carried, a
|
// The gesture the tree rearrangement exists for: no images carried, a
|
||||||
|
|||||||
+347
-75
@@ -30,6 +30,7 @@ use dr_catalog::faces::{self, DetectedFace};
|
|||||||
use dr_catalog::Catalog;
|
use dr_catalog::Catalog;
|
||||||
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
|
use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId};
|
||||||
use dr_thumbs::{ThumbSize, ThumbStore};
|
use dr_thumbs::{ThumbSize, ThumbStore};
|
||||||
|
use dr_types::settings::FaceSettings;
|
||||||
use dr_types::ImageId;
|
use dr_types::ImageId;
|
||||||
|
|
||||||
/// The tier faces are found on. See the module note.
|
/// The tier faces are found on. See the module note.
|
||||||
@@ -476,6 +477,148 @@ pub fn spawn_store_face_sweep(
|
|||||||
rx
|
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.
|
/// Group the library's faces into people, writing suggestions.
|
||||||
///
|
///
|
||||||
/// Confirmations are never touched: they enter the clusterer as anchors and
|
/// Confirmations are never touched: they enter the clusterer as anchors and
|
||||||
@@ -487,72 +630,22 @@ pub fn spawn_store_face_sweep(
|
|||||||
pub fn recluster(
|
pub fn recluster(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
model_id: &str,
|
model_id: &str,
|
||||||
min_probability: f32,
|
grouping: &FaceSettings,
|
||||||
) -> Result<(usize, usize), dr_catalog::CatalogError> {
|
) -> 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();
|
let conn = catalog.connection();
|
||||||
|
|
||||||
// No valid calibration is not a reason to refuse to cluster — it is a
|
let Population {
|
||||||
// reason not to *display* a confidence (FR-CULL-9). `Calibration::default`
|
cal,
|
||||||
// is the reference implementation's fitted curve with `valid` false, which
|
candidates,
|
||||||
// is a documented operating point rather than an invented one.
|
ids,
|
||||||
let cal = faces::calibration(conn, model_id)?
|
anchors: confirmed,
|
||||||
.map(|(c, _)| c)
|
} = Population::read(catalog, model_id)?;
|
||||||
.unwrap_or_else(Calibration::default);
|
if candidates.is_empty() {
|
||||||
|
|
||||||
let stored = faces::embeddings(conn, model_id)?;
|
|
||||||
if stored.is_empty() {
|
|
||||||
return Ok((0, 0));
|
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 {
|
let dr_face::Grouping {
|
||||||
clusters,
|
clusters,
|
||||||
confidence,
|
confidence,
|
||||||
@@ -560,10 +653,20 @@ pub fn recluster(
|
|||||||
|
|
||||||
let mut suggested = 0usize;
|
let mut suggested = 0usize;
|
||||||
let mut created = 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 {
|
for c in &clusters {
|
||||||
// A group of one is not a person. Naming every stray face would fill
|
// A group of one is not a person, and the user says how much bigger
|
||||||
// the People view with noise the user then has to dismiss.
|
// than one it has to be (`FaceSettings::min_group_size`). Naming every
|
||||||
if c.members.len() < 2 && c.person.is_none() {
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +682,7 @@ pub fn recluster(
|
|||||||
|
|
||||||
for &m in &c.members {
|
for &m in &c.members {
|
||||||
let face = ids[m];
|
let face = ids[m];
|
||||||
|
placed.insert(face);
|
||||||
if confirmed.contains_key(&face) {
|
if confirmed.contains_key(&face) {
|
||||||
continue;
|
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
|
// 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
|
// 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
|
// believes in, and the screen fills with "Unnamed (0 faces)" — which is
|
||||||
@@ -612,6 +741,43 @@ pub fn recluster(
|
|||||||
Ok((suggested, created))
|
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.
|
/// Progress from a regrouping pass.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ReclusterMessage {
|
pub enum ReclusterMessage {
|
||||||
@@ -643,7 +809,7 @@ pub enum ReclusterMessage {
|
|||||||
pub fn spawn_recluster(
|
pub fn spawn_recluster(
|
||||||
catalog_path: PathBuf,
|
catalog_path: PathBuf,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
min_probability: f32,
|
grouping: FaceSettings,
|
||||||
) -> Receiver<ReclusterMessage> {
|
) -> Receiver<ReclusterMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
@@ -667,7 +833,7 @@ pub fn spawn_recluster(
|
|||||||
return;
|
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 },
|
Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created },
|
||||||
Err(e) => ReclusterMessage::Failed(e.to_string()),
|
Err(e) => ReclusterMessage::Failed(e.to_string()),
|
||||||
};
|
};
|
||||||
@@ -1125,7 +1291,7 @@ mod tests {
|
|||||||
put_face(&catalog, 1, 0, 1.0);
|
put_face(&catalog, 1, 0, 1.0);
|
||||||
put_face(&catalog, 2, 0, 0.99);
|
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();
|
let people = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(people.len(), 1, "the two faces should have grouped");
|
assert_eq!(people.len(), 1, "the two faces should have grouped");
|
||||||
let stranger = people[0].id;
|
let stranger = people[0].id;
|
||||||
@@ -1133,7 +1299,7 @@ mod tests {
|
|||||||
|
|
||||||
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
|
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();
|
let after = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1157,13 +1323,13 @@ mod tests {
|
|||||||
put_face(&catalog, 1, 0, 1.0);
|
put_face(&catalog, 1, 0, 1.0);
|
||||||
put_face(&catalog, 2, 0, 0.99);
|
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;
|
let stranger = faces::people(catalog.connection()).unwrap()[0].id;
|
||||||
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
|
faces::set_ignored(catalog.connection(), stranger, true).unwrap();
|
||||||
|
|
||||||
// The same person turns up in a third photograph.
|
// The same person turns up in a third photograph.
|
||||||
put_face(&catalog, 3, 0, 0.98);
|
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();
|
let after = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(after.len(), 1, "a new face made a second group: {after:?}");
|
assert_eq!(after.len(), 1, "a new face made a second group: {after:?}");
|
||||||
@@ -1177,13 +1343,13 @@ mod tests {
|
|||||||
let catalog = catalog_with(3);
|
let catalog = catalog_with(3);
|
||||||
put_face(&catalog, 1, 0, 1.0);
|
put_face(&catalog, 1, 0, 1.0);
|
||||||
put_face(&catalog, 2, 0, 0.99);
|
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;
|
let id = faces::people(catalog.connection()).unwrap()[0].id;
|
||||||
faces::set_ignored(catalog.connection(), id, true).unwrap();
|
faces::set_ignored(catalog.connection(), id, true).unwrap();
|
||||||
faces::set_ignored(catalog.connection(), id, false).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();
|
let after = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(after.len(), 1);
|
assert_eq!(after.len(), 1);
|
||||||
assert!(!after[0].ignored);
|
assert!(!after[0].ignored);
|
||||||
@@ -1200,7 +1366,7 @@ mod tests {
|
|||||||
put_face(&catalog, 1, 0, 1.0);
|
put_face(&catalog, 1, 0, 1.0);
|
||||||
put_face(&catalog, 2, 0, 0.99);
|
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();
|
let people = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(people.len(), 1);
|
assert_eq!(people.len(), 1);
|
||||||
let her = people[0].id;
|
let her = people[0].id;
|
||||||
@@ -1210,7 +1376,7 @@ mod tests {
|
|||||||
// types a name and moves on has done.
|
// types a name and moves on has done.
|
||||||
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
|
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();
|
let after = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1233,15 +1399,121 @@ mod tests {
|
|||||||
let catalog = catalog_with(3);
|
let catalog = catalog_with(3);
|
||||||
put_face(&catalog, 1, 0, 1.0);
|
put_face(&catalog, 1, 0, 1.0);
|
||||||
put_face(&catalog, 2, 0, 0.99);
|
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;
|
let her = faces::people(catalog.connection()).unwrap()[0].id;
|
||||||
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
|
faces::rename_person(catalog.connection(), her, "Catherine").unwrap();
|
||||||
|
|
||||||
put_face(&catalog, 3, 0, 0.98);
|
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();
|
let after = faces::people(catalog.connection()).unwrap();
|
||||||
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
|
assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}");
|
||||||
assert_eq!(after[0].suggested_faces, 3);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Everything the Identity screen draws.
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct IdentityView {
|
pub struct IdentityView {
|
||||||
@@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result<u64, dr_catalog::CatalogError> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::faces::GroupingPreview;
|
||||||
use dr_catalog::faces::DetectedFace;
|
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 {
|
fn catalog() -> Catalog {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
"dr-identity-{}-{:?}",
|
"dr-identity-{}-{:?}",
|
||||||
|
|||||||
+137
-1
@@ -81,6 +81,12 @@ pub struct IdentityController {
|
|||||||
/// the handle, and clustering a real library is not something a Slint
|
/// the handle, and clustering a real library is not something a Slint
|
||||||
/// callback may do on the UI thread.
|
/// callback may do on the UI thread.
|
||||||
regroup: RefCell<Option<Receiver<crate::faces::ReclusterMessage>>>,
|
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.
|
/// Whether the rail is showing the people the user has set aside.
|
||||||
show_ignored: std::cell::Cell<bool>,
|
show_ignored: std::cell::Cell<bool>,
|
||||||
/// Rail portraits, kept between refreshes.
|
/// 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.
|
/// The detector and embedder files, when both are present.
|
||||||
pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf);
|
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.
|
/// 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>(
|
pub fn wire<S, M, P>(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
ctl: Rc<IdentityController>,
|
ctl: Rc<IdentityController>,
|
||||||
catalog: Rc<RefCell<Option<Catalog>>>,
|
catalog: Rc<RefCell<Option<Catalog>>>,
|
||||||
activity: Rc<crate::activity::ActivityLog>,
|
activity: Rc<crate::activity::ActivityLog>,
|
||||||
|
settings: Rc<crate::settings_ui::SettingsController>,
|
||||||
store: S,
|
store: S,
|
||||||
models: M,
|
models: M,
|
||||||
paths: P,
|
paths: P,
|
||||||
@@ -396,6 +423,36 @@ pub fn wire<S, M, P>(
|
|||||||
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
let models: Rc<dyn Fn() -> Option<ModelPaths>> = Rc::new(models);
|
||||||
let paths: Rc<dyn Fn() -> Option<SweepPaths>> = Rc::new(paths);
|
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
|
// Re-read everything and redraw. Every mutating callback ends in this
|
||||||
// rather than patching the model in place: the operations here have
|
// rather than patching the model in place: the operations here have
|
||||||
// second-order effects — a merge empties a person, a split creates one,
|
// 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 weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
let catalog = catalog.clone();
|
let catalog = catalog.clone();
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
let paths = paths.clone();
|
let paths = paths.clone();
|
||||||
|
let settings_for_regroup = settings.clone();
|
||||||
window.on_identity_recluster(move || {
|
window.on_identity_recluster(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
// One at a time. Two passes over the same faces would each create
|
// 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(
|
*ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster(
|
||||||
catalog_path,
|
catalog_path,
|
||||||
MODEL_ID.to_string(),
|
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
|
// 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));
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+14
-10
@@ -1013,6 +1013,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// faces are ticked for a split.
|
// faces are ticked for a split.
|
||||||
let identity = std::rc::Rc::new(identity_ui::IdentityController::new());
|
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
|
// TRACES: FR-PLAT-AND-5
|
||||||
// The thumbnail tier. Registered here, beside the thing it frees, so that
|
// 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.
|
// 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(),
|
identity.clone(),
|
||||||
library.catalog(),
|
library.catalog(),
|
||||||
activity.clone(),
|
activity.clone(),
|
||||||
|
settings.clone(),
|
||||||
move || {
|
move || {
|
||||||
let conn = lib_store.session()?;
|
let conn = lib_store.session()?;
|
||||||
dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account))
|
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
|
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b
|
||||||
// Import: a card into the library, and on to the server.
|
// Import: a card into the library, and on to the server.
|
||||||
//
|
//
|
||||||
|
|||||||
+334
-6
@@ -3910,9 +3910,10 @@ pub fn read_cells(
|
|||||||
/// look broken. The id list comes from
|
/// look broken. The id list comes from
|
||||||
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
|
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
|
||||||
///
|
///
|
||||||
/// Ordering matches the unscoped grid (capture time, then name) rather than
|
/// Ordering comes from [`grid_order_for`]: manual position for a single manual
|
||||||
/// manual position: position is only meaningful inside one collection and this
|
/// collection, capture time for a set — because position is only meaningful
|
||||||
/// query also serves sets, where two children's positions are unrelated.
|
/// inside one collection, and this query also serves sets, where two children's
|
||||||
|
/// positions are unrelated integers.
|
||||||
pub fn read_cells_scoped(
|
pub fn read_cells_scoped(
|
||||||
catalog: &Catalog,
|
catalog: &Catalog,
|
||||||
scope: Option<dr_types::CollectionId>,
|
scope: Option<dr_types::CollectionId>,
|
||||||
@@ -3930,6 +3931,7 @@ pub fn read_cells_scoped(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let rated = filter.sql();
|
let rated = filter.sql();
|
||||||
|
let (order, order_params) = grid_order_for(catalog, Some(scope));
|
||||||
let folded = uncollapsed("i");
|
let folded = uncollapsed("i");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {CELL_COLUMNS}
|
"SELECT {CELL_COLUMNS}
|
||||||
@@ -3937,14 +3939,17 @@ pub fn read_cells_scoped(
|
|||||||
WHERE {VISIBLE}{rated}{folded}
|
WHERE {VISIBLE}{rated}{folded}
|
||||||
AND i.id IN (SELECT image_id FROM collection_members
|
AND i.id IN (SELECT image_id FROM collection_members
|
||||||
WHERE collection_id IN ({placeholders}))
|
WHERE collection_id IN ({placeholders}))
|
||||||
{GRID_ORDER}
|
{order}
|
||||||
LIMIT ? OFFSET ?"
|
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
|
let mut params: Vec<rusqlite::types::Value> = ids
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
||||||
.collect();
|
.collect();
|
||||||
|
params.extend(order_params);
|
||||||
params.push(rusqlite::types::Value::Integer(limit as i64));
|
params.push(rusqlite::types::Value::Integer(limit as i64));
|
||||||
params.push(rusqlite::types::Value::Integer(offset as i64));
|
params.push(rusqlite::types::Value::Integer(offset as i64));
|
||||||
|
|
||||||
@@ -4070,14 +4075,23 @@ pub fn read_ids_span(
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let (clause, params) = scope_clause(catalog, scope)?;
|
let (clause, mut params) = scope_clause(catalog, scope)?;
|
||||||
let rated = filter.sql();
|
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");
|
let folded = uncollapsed("i");
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
"SELECT i.id FROM images i
|
"SELECT i.id FROM images i
|
||||||
WHERE {VISIBLE}{rated}{folded}{clause}
|
WHERE {VISIBLE}{rated}{folded}{clause}
|
||||||
{GRID_ORDER}
|
{order}
|
||||||
LIMIT ? OFFSET ?"
|
LIMIT ? OFFSET ?"
|
||||||
),
|
),
|
||||||
params,
|
params,
|
||||||
@@ -4225,6 +4239,146 @@ pub fn total_images_scoped(
|
|||||||
Ok(n as usize)
|
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
|
/// The SQL restricting a query to `scope` and its descendants, with the bound
|
||||||
/// parameters to go with it.
|
/// parameters to go with it.
|
||||||
///
|
///
|
||||||
@@ -5510,6 +5664,180 @@ mod tests {
|
|||||||
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
|
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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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]
|
#[test]
|
||||||
fn the_most_recently_trashed_image_is_listed_first() {
|
fn the_most_recently_trashed_image_is_listed_first() {
|
||||||
// A mistaken delete is corrected within seconds, so the row the user
|
// A mistaken delete is corrected within seconds, so the row the user
|
||||||
|
|||||||
@@ -4527,6 +4527,13 @@ pub fn wire<F>(
|
|||||||
if coll_for_click.press_was_modified() {
|
if coll_for_click.press_was_modified() {
|
||||||
return;
|
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();
|
let path = ctl.paths.borrow().get(i as usize).cloned();
|
||||||
if let Some(path) = path {
|
if let Some(path) = path {
|
||||||
// Leave the grid for the develop view. The status bar's
|
// 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 weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
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(
|
.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(
|
||||||
Vec::<PersonChip>::new(),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5624,6 +5662,12 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
PersonChip {
|
PersonChip {
|
||||||
id: *id as i32,
|
id: *id as i32,
|
||||||
name: name.into(),
|
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();
|
.collect();
|
||||||
@@ -5633,6 +5677,71 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
ctl.filter.borrow().people_mode,
|
ctl.filter.borrow().people_mode,
|
||||||
library::PeopleMode::All
|
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>) {
|
fn refilter(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
|
|||||||
@@ -111,7 +111,13 @@ impl SettingsController {
|
|||||||
/// accidentally write back a stale copy of the fields it was not editing —
|
/// 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
|
/// with a save on every keystroke, two controls holding their own snapshots
|
||||||
/// would overwrite each other.
|
/// 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();
|
let mut settings = self.settings.borrow_mut();
|
||||||
f(&mut settings);
|
f(&mut settings);
|
||||||
|
|||||||
+42
-5
@@ -309,7 +309,16 @@ export component AppWindow inherits Window {
|
|||||||
callback library-toggle-date-range();
|
callback library-toggle-date-range();
|
||||||
/// TRACES: FR-CAT-5
|
/// TRACES: FR-CAT-5
|
||||||
callback library-clear-selection();
|
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
|
/// TRACES: FR-CAT-6
|
||||||
in property <string> library-range-from;
|
in property <string> library-range-from;
|
||||||
in property <string> library-range-to;
|
in property <string> library-range-to;
|
||||||
@@ -410,6 +419,15 @@ export component AppWindow inherits Window {
|
|||||||
callback identity-recluster();
|
callback identity-recluster();
|
||||||
in property <bool> identity-regrouping: false;
|
in property <bool> identity-regrouping: false;
|
||||||
in property <string> identity-regroup-status;
|
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 <int> identity-ignored-count: 0;
|
||||||
in property <bool> identity-show-ignored: false;
|
in property <bool> identity-show-ignored: false;
|
||||||
in property <bool> identity-selected-ignored: false;
|
in property <bool> identity-selected-ignored: false;
|
||||||
@@ -566,7 +584,7 @@ export component AppWindow inherits Window {
|
|||||||
callback library-drag-finished();
|
callback library-drag-finished();
|
||||||
in property <int> library-selected-count: 0;
|
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();
|
callback library-remove-from-collection();
|
||||||
|
|
||||||
// The keyboard cursor: where the arrows are in the library, as an image
|
// 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;
|
in property <bool> library-filter-people-all: false;
|
||||||
callback library-filter-person-cleared(int);
|
callback library-filter-person-cleared(int);
|
||||||
callback library-filter-people-mode-toggled();
|
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 <int> library-filter-min-rating: 0;
|
||||||
in property <bool> library-filter-unjudged: false;
|
in property <bool> library-filter-unjudged: false;
|
||||||
in property <int> library-filter-flag: 0;
|
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;
|
photos-filtered: root.library-filter-people.length > 0;
|
||||||
regrouping: root.identity-regrouping;
|
regrouping: root.identity-regrouping;
|
||||||
regroup-status: root.identity-regroup-status;
|
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;
|
ignored-count: root.identity-ignored-count;
|
||||||
show-ignored: root.identity-show-ignored;
|
show-ignored: root.identity-show-ignored;
|
||||||
selected-ignored: root.identity-selected-ignored;
|
selected-ignored: root.identity-selected-ignored;
|
||||||
@@ -1429,7 +1458,12 @@ in property <bool> panel-visible: true;
|
|||||||
range-active: root.library-range-active;
|
range-active: root.library-range-active;
|
||||||
toggle-date-range() => { root.library-toggle-date-range(); }
|
toggle-date-range() => { root.library-toggle-date-range(); }
|
||||||
clear-selection() => { root.library-clear-selection(); }
|
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-from: root.library-range-from;
|
||||||
range-to: root.library-range-to;
|
range-to: root.library-range-to;
|
||||||
range-invalid: root.library-range-invalid;
|
range-invalid: root.library-range-invalid;
|
||||||
@@ -1511,8 +1545,8 @@ in property <bool> panel-visible: true;
|
|||||||
open-settings() => { root.settings-open(); }
|
open-settings() => { root.settings-open(); }
|
||||||
open-people() => { root.identity-open(); }
|
open-people() => { root.identity-open(); }
|
||||||
|
|
||||||
cell-pressed(i, ctrl, shift) => {
|
cell-pressed(i, ctrl, shift, touch) => {
|
||||||
root.library-cell-pressed(i, ctrl, shift);
|
root.library-cell-pressed(i, ctrl, shift, touch);
|
||||||
}
|
}
|
||||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
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: root.library-filter-people;
|
||||||
filter-people-all: root.library-filter-people-all;
|
filter-people-all: root.library-filter-people-all;
|
||||||
|
people: root.library-people;
|
||||||
filter-min-rating: root.library-filter-min-rating;
|
filter-min-rating: root.library-filter-min-rating;
|
||||||
filter-unjudged: root.library-filter-unjudged;
|
filter-unjudged: root.library-filter-unjudged;
|
||||||
filter-flag: root.library-filter-flag;
|
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-flag-changed(f) => { root.library-filter-flag-changed(f); }
|
||||||
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
|
filter-person-cleared(id) => { root.library-filter-person-cleared(id); }
|
||||||
filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); }
|
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
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Panel, Button, IconButton, Field } from "widgets.slint";
|
import { Panel, Button, IconButton, Field } from "widgets.slint";
|
||||||
|
import { SliderRow } from "controls.slint";
|
||||||
import { Icon } from "icons.slint";
|
import { Icon } from "icons.slint";
|
||||||
|
|
||||||
export struct IdentityPerson {
|
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.
|
/// live and has to say what it is doing rather than simply stopping.
|
||||||
in property <bool> regrouping: false;
|
in property <bool> regrouping: false;
|
||||||
in property <string> regroup-status;
|
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.
|
/// People the user has set aside, and whether the rail is showing them.
|
||||||
in property <int> ignored-count: 0;
|
in property <int> ignored-count: 0;
|
||||||
in property <bool> show-ignored: false;
|
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.
|
/// "and also" button only appears when there is something to add to.
|
||||||
in property <bool> photos-filtered: false;
|
in property <bool> photos-filtered: false;
|
||||||
callback recluster();
|
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 index-faces();
|
||||||
callback stop-indexing();
|
callback stop-indexing();
|
||||||
callback check-coverage();
|
callback check-coverage();
|
||||||
@@ -433,7 +454,16 @@ export component IdentityScreen inherits Rectangle {
|
|||||||
: 0px;
|
: 0px;
|
||||||
visible: root.selected-person >= 0;
|
visible: root.selected-person >= 0;
|
||||||
edited(t) => { root.name-edited(t); }
|
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 {
|
if root.selected-person < 0: Text {
|
||||||
text: "Select a person";
|
text: "Select a person";
|
||||||
@@ -521,6 +551,106 @@ export component IdentityScreen inherits Rectangle {
|
|||||||
enabled: !root.regrouping;
|
enabled: !root.regrouping;
|
||||||
clicked => { root.recluster(); }
|
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
@@ -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 {
|
export struct PersonChip {
|
||||||
id: int,
|
id: int,
|
||||||
name: string,
|
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 {
|
export struct LibraryCell {
|
||||||
@@ -1230,7 +1240,15 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// also why there is no badge position to compute here any more.
|
// also why there is no badge position to compute here any more.
|
||||||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||||||
/// without the .slint file encoding the selection policy.
|
/// 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
|
/// TRACES: FR-UI-2 | FR-UI-4
|
||||||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
/// 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
|
/// 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;
|
in property <bool> select-mode: false;
|
||||||
callback toggle-select-mode();
|
callback toggle-select-mode();
|
||||||
/// TRACES: FR-CAT-5
|
/// 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.
|
/// Drop the selection without leaving select mode.
|
||||||
callback clear-selection();
|
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
|
/// TRACES: FR-CAT-5
|
||||||
/// Make a new collection holding exactly what is selected.
|
/// Make a new collection, under the given name, holding exactly what is
|
||||||
callback collection-from-selection();
|
/// 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
|
/// 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.
|
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||||
pure callback drag-payload() -> data-transfer;
|
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
|
/// Whose photographs the grid is narrowed to — one chip each, so a
|
||||||
/// selection of three can be taken apart one person at a time.
|
/// selection of three can be taken apart one person at a time.
|
||||||
in property <[PersonChip]> filter-people;
|
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.
|
/// Whether those people are an intersection rather than a union.
|
||||||
in property <bool> filter-people-all: false;
|
in property <bool> filter-people-all: false;
|
||||||
callback filter-person-cleared(int);
|
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.
|
/// disclosure rather than a preference, and what closes it is dismissing it.
|
||||||
property <bool> keywording: false;
|
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
|
// Cell geometry. Columns are derived from the available width so the grid
|
||||||
// reflows with the window rather than fixing a count (FR-UI-1).
|
// reflows with the window rather than fixing a count (FR-UI-1).
|
||||||
// Zoomable, so the grid serves both jobs: fewer, larger images for
|
// 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(); }
|
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 {
|
Caption {
|
||||||
text: "Show";
|
text: "Show";
|
||||||
vertical-alignment: center;
|
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 --------------------
|
// --- what is selected, and what to do with it --------------------
|
||||||
//
|
//
|
||||||
// The count and these actions used to live only in the header row,
|
// 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;
|
spacing: Theme.gap-sm;
|
||||||
alignment: start;
|
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
|
// State rather than a label: it is what the buttons beside it
|
||||||
// act on.
|
// act on.
|
||||||
Value {
|
if !root.ranging: Value {
|
||||||
text: root.selected-count + (root.selected-count == 1
|
text: root.selected-count + (root.selected-count == 1
|
||||||
? " photograph selected" : " photographs selected");
|
? " photograph selected" : " photographs selected");
|
||||||
modified: true;
|
modified: true;
|
||||||
@@ -2081,26 +2328,50 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { width: Theme.gap; }
|
if !root.ranging: Rectangle { width: Theme.gap; }
|
||||||
|
|
||||||
// The way out that is not "undo every tap". Distinct from
|
// The way out that is not "undo every tap". Distinct from
|
||||||
// "Done", which leaves select mode entirely: clearing keeps the
|
// "Done", which leaves select mode entirely: clearing keeps the
|
||||||
// mode, so the next selection can start straight away.
|
// mode, so the next selection can start straight away.
|
||||||
Button {
|
if !root.ranging: Button {
|
||||||
text: "Clear";
|
text: "Clear";
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
clicked => { root.clear-selection(); }
|
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
|
// Filing a selection into a collection that does not exist yet
|
||||||
// took four steps: make a collection, find it, select the
|
// took four steps: make a collection, find it, select the
|
||||||
// photographs again, add them. One press instead, which is how
|
// photographs again, add them. One press instead, which is how
|
||||||
// a selection is usually meant.
|
// a selection is usually meant.
|
||||||
Button {
|
// No longer "…from selection": the sheet it opens says
|
||||||
text: "New collection from selection";
|
// "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;
|
primary: true;
|
||||||
y: (parent.height - self.height) / 2;
|
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(); }
|
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 {
|
Rectangle {
|
||||||
// Lifted cells shrink toward their own centre, as though pulled
|
// Lifted cells shrink toward their own centre, as though pulled
|
||||||
// off the page. Inset rather than scaled: Slint has no transform
|
// 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
|
// over from the gesture, handed back as a new
|
||||||
// press. See `pinching`.
|
// press. See `pinching`.
|
||||||
if (!root.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(
|
root.cell-pressed(
|
||||||
i,
|
i,
|
||||||
ev.modifiers.control || root.select-mode,
|
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) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,35 @@ export component Field inherits Rectangle {
|
|||||||
/// because the breakage is in Slint's binding model, not the styling.
|
/// because the breakage is in Slint's binding model, not the styling.
|
||||||
callback edited(string);
|
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;
|
height: Theme.touch-target;
|
||||||
border-radius: Theme.radius;
|
border-radius: Theme.radius;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
|||||||
Reference in New Issue
Block a user