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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:53:13 +02:00
co-authored by Claude Opus 5
17 changed files with 2207 additions and 165 deletions
+17
View File
@@ -477,6 +477,23 @@ pub fn collections_for_image(
Ok(rows) 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
+155
View File
@@ -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();
+34
View File
@@ -788,6 +788,40 @@ It is **not** a merge threshold and must not become one. Uniqueness is relative,
one named person would hand every stray face a 1. "Is this the same person at all" stays §8's 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
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -88,7 +88,11 @@ fn main() {
// whole-library operation over the embeddings detection produced, and it is // 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
View File
@@ -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(&current, &moving, target, after);
match coll::set_order(cat.connection(), scope, &wanted) {
Ok(()) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{} photograph{} moved",
moving.len(),
if moving.len() == 1 { "" } else { "s" }
)
.into(),
);
drop(borrow);
// The selection survives. It is what was just moved, and
// dropping it would make a second nudge — which is how a
// drag-to-reorder is usually corrected — start over.
reload();
sync_selection(&w, &ctl, &visible());
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("reordering: {e}").into());
}
}
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// Everything the grid is showing.
//
// Asked of the catalog through `span`, not read off the loaded window, for
// the reason spelled out in `apply_press`: the window is a hundred cells
// over a library of thousands, and a "select all" that quietly meant
// "select the hundred that happen to be loaded" is a lie the user cannot
// see until the export runs. `library_total` is the count the same scope
// and filter produced, so the run is the whole of what the grid claims.
//
// Replaces rather than adds. "All" is a statement about the result, not an
// increment, and a user who wanted the rest kept would not have reached for
// this.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_select_all(move || {
let Some(w) = weak.upgrade() else { return };
let ids = visible();
let total = w.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The loaded window is the fallback, not the answer — the same
// trade the shift-click path makes. An empty span means no library
// or a failed query, and selecting what is on screen beats
// selecting nothing.
let all = match ctl.span(0, total - 1) {
run if run.is_empty() => ids.clone(),
run => run,
};
{
let mut selection = ctl.selection.borrow_mut();
selection.clear();
selection.extend(all);
}
// The first frame becomes the anchor, so a "Select to…" straight
// afterwards describes a range from the top rather than from
// wherever the last individual tap left it — which, after taking
// everything, is not a place the user is still thinking about.
*ctl.anchor.borrow_mut() = Some(0);
sync_selection(&w, &ctl, &ids);
});
}
// TRACES: FR-CAT-5 // 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
View File
@@ -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);
}
} }
+48
View File
@@ -117,6 +117,29 @@ impl FaceCell {
} }
} }
/// Put a grouping preview into words.
///
/// **The group count leads, not the grouped-face count.** They move in opposite
/// directions on either side of the right setting, and only one of them says
/// which side you are on: loosening gathers fragments into people, so the group
/// count climbs — until it starts welding separate people together, at which
/// point it *falls* while the grouped faces keep rising. `dr_face::cluster`
/// records that measurement at length. A line that led with "1,340 of 1,813
/// faces grouped" would make the over-merged setting look like the best one.
///
/// The largest group is here for the same reason: it is where over-merging
/// shows up first and most legibly, because a user who knows their own library
/// knows whether anyone in it has been photographed six hundred times.
pub fn preview_label(p: &crate::faces::GroupingPreview) -> String {
if p.faces == 0 {
return "No faces indexed yet, so there is nothing to group.".into();
}
format!(
"{} group(s), holding {} of {} faces. Largest: {}.",
p.groups, p.grouped, p.faces, p.largest
)
}
/// Everything the Identity screen draws. /// 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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&current, &ids(&[4]), dr_types::ImageId(2), false),
ids(&[1, 4, 2, 3, 5])
);
}
#[test]
fn a_run_moved_backward_lands_before_it_too() {
// The direction of travel must not change what "before this one" means,
// or the same drop would land in two different places depending on
// where the photograph came from.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[2]), dr_types::ImageId(5), false),
ids(&[1, 3, 4, 2, 5])
);
}
#[test]
fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() {
// There is no cell beyond the last one to drop in front of, so without
// `after` the final position is unreachable — which is exactly the
// place a "put this at the end" drag is aiming for.
let current = ids(&[1, 2, 3]);
assert_eq!(
reordered(&current, &ids(&[1]), dr_types::ImageId(3), true),
ids(&[2, 3, 1])
);
}
#[test]
fn a_moved_run_keeps_the_order_the_grid_shows_it_in() {
// Not the order the selection was built in. A user who tapped the last
// frame first has said nothing about how the run should be arranged —
// only about where it should go.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[5, 1]), dr_types::ImageId(3), false),
ids(&[2, 1, 5, 3, 4])
);
}
#[test]
fn a_run_dropped_on_one_of_its_own_members_stays_together() {
// The caller refuses this drop, so it is only reachable if that guard
// is ever lost. It must not lose photographs when it is.
let current = ids(&[1, 2, 3, 4]);
let moved = reordered(&current, &ids(&[2, 3]), dr_types::ImageId(3), false);
assert_eq!(moved.len(), current.len(), "nothing was dropped");
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented");
}
#[test]
fn a_reorder_never_loses_or_duplicates_a_member() {
// The property that matters most: this writes the whole membership
// back, so a run that dropped one image would delete it from the
// collection.
let current = ids(&[1, 2, 3, 4, 5, 6]);
for target in [1u64, 2, 3, 4, 5, 6] {
for after in [false, true] {
let moved = reordered(&current, &ids(&[2, 5]), dr_types::ImageId(target), after);
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(
sorted,
ids(&[1, 2, 3, 4, 5, 6]),
"target {target}, after {after}"
);
}
}
}
/// The scoped grid and the range a shift-click resolves are two halves of
/// one ordering. This is the assertion that keeps them one: an ordinal read
/// through a different ORDER BY names a different photograph, and the user
/// finds out when the export runs.
#[test]
fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() {
let catalog = with_images(5);
let all = image_ids(&catalog);
let id = dr_catalog::collections::create(
catalog.connection(),
"Trip",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap();
// Reversed, so position and capture time disagree about everything.
let wanted: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, wanted, "the grid draws the order that was written");
let spanned =
read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap();
assert_eq!(spanned, wanted, "and a range resolves through the same one");
assert_eq!(
read_member_order(&catalog, id).unwrap(),
wanted,
"and so does the membership a reorder rewrites"
);
}
#[test]
fn a_collection_with_children_falls_back_to_capture_time() {
// A set draws its descendants' images too, and two children's positions
// are unrelated integers. Ordering by them interleaves the two
// arbitrarily, which is worse than an order that at least means
// something.
let catalog = with_images(4);
let all = image_ids(&catalog);
let parent = dr_catalog::collections::create(
catalog.connection(),
"Iceland",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::create(
catalog.connection(),
"Day one",
Some(parent),
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap();
let reversed: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
let cells =
read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, all, "capture time, not the positions that were set");
}
#[test]
fn a_smart_collection_has_no_manual_order_to_read() {
// No member rows at all, so `position` is not a column any of its
// images have. Falling back is the only thing there is to do.
let catalog = with_images(3);
let id = dr_catalog::collections::create(
catalog.connection(),
"Picks",
None,
dr_catalog::collections::CollectionKind::Smart,
)
.unwrap();
let (order, params) = grid_order_for(&catalog, Some(id));
assert_eq!(order, GRID_ORDER);
assert!(params.is_empty());
}
#[test] #[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
+109
View File
@@ -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>) {
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -414,10 +414,20 @@ export component Timeline inherits Rectangle {
} }
} }
/// One person the grid is narrowed to, as drawn on the filter bar. /// One person on the filter bar, in either of the two roles it plays there.
///
/// The chips for who the grid is *currently* narrowed to, and the roster in the
/// tray the user picks from, are the same thing drawn twice — same id, same
/// wording — so they are one struct. `faces` and `picked` are the tray's half
/// and are simply not read by the narrowed-to chips.
export struct PersonChip { 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;
}
}
}
}
}
}
} }
+29
View File
@@ -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;